feat(console): let a guild bring its own sign-in without touching a deployment - #153
Open
TheMeinerLP wants to merge 1 commit into
Open
feat(console): let a guild bring its own sign-in without touching a deployment#153TheMeinerLP wants to merge 1 commit into
TheMeinerLP wants to merge 1 commit into
Conversation
…eployment A guild has been able to sign its people in against its own Outline since #147, and until now the only way to arrange it was five `curl` calls: the `guild_oauth_client` rows existed and nothing in the browser ever wrote one. `/admin/sign-in-link` is that page, and `/g/{slug}/sign-in` is the address `domain/oauth_clients.py` has named since #147 and nothing has ever served. The registration form has no credential field at all. `ClientDraft` has nowhere to put one, so "saving a change of base URL wiped the client secret" is not a bug this page avoids, it is a request this page cannot construct — #150's construction for an export credential, applied to the credential that decides who gets a session. The secret is a separate control with its own two routes, its box does not exist until somebody presses for it, nothing typed outlives the panel, and there is no mask: a row of dots is a value, and a value would have had to come from somewhere. Two disclosures the design refuses, and both are enforced here rather than described. Nothing in the console asks whether a sign-in name is free — the API answers one 409 for "another guild holds it" and for "this deployment reserves it" so that the two cannot be told apart, and a console carrying its own copy of the reserved list would undo that from the outside. And the public page performs no lookup whatsoever, so a registered name, a half-configured one and a name nobody has ever claimed render byte for byte alike; the middleware's allowlist is deliberately looser than the slug rule for the same reason, since bouncing a malformed name to a different screen would be the oracle again. The page also says what a guild's client does not govern: linking a Discord account with `/link` stays on the environment-configured client permanently, because `link` does not hold the master key and `charts/sturnus/templates/_helpers.tpl` refuses to render it there. Every string is keyed in both languages. The nine exports that collided with `~/utils/exportTargets` are prefixed, because Nuxt auto-imports all of `app/utils` and a collision is resolved by the build rather than by the reader.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds the console page for the per-guild sign-in that #147 shipped, and the page a guild's link actually points at.
What it does
#147gave a guild the ability to sign its people in against its own Outline rather than against the one this deployment is configured with — the whole of §2.2, and the reason Sturnus can be a product rather than one organisation's deployment. It arrived reachable only fromcurl: theguild_oauth_clientrows existed, five routes wrote them, and nothing in a browser ever had./admin/sign-in-linkis that page. It reads a guild's registration, registers or replaces it, removes it, and stores or clears its client secret — one guild at a time, through the same switcher Bot Settings, Destinations and the Queue already share, remembered choice included. It says which of three states the guild's link is in, and shows the link itself in a box that can be selected, read aloud and copied./g/{slug}/sign-inis the second half, and it did not exist either.domain/oauth_clients.pyhas said since #147 that "the console publishes a guild's link at/g/{slug}/sign-in", and nothing served that address — so the only link an administrator could hand out was/api/auth/login?guild=acme, which is a redirect with no page. Somebody following it half-configured meets a raw JSON body; somebody following it fully configured never sees this deployment at all. The page in between is where the product gets to say whose sign-in this is, and it is on the middleware's public allowlist because it exists before a session does.Why it is shaped this way
The registration form has no credential field, and the type behind it has nowhere to put one. This is #150's construction for an export credential, applied to a credential with higher stakes: whoever controls the identity provider behind a slug controls who this console believes is signing in.
ClientDraftcarries five fields and no sixth, so "saving a change of base URL wiped the working client secret" is not a failure this page avoids — it is a request this page cannot construct. The secret has its own control with its own two routes (PUTandDELETEon.../oauth-client/secret); its input does not exist until somebody deliberately presses for it; what was typed is dropped when the panel closes, when the answer comes back, and when the guild changes underneath; clearing is a second, confirmed act that says what it costs. There is no mask. A row of dots is a value — it says how long the credential is — and it promises a "show" button this API cannot serve.The slug is checked for shape and never for availability.
routes_oauthanswers 400 to a name that is not spelled like one and 409 to a name that is spelled correctly and is not this guild's to have — and it gives that same 409 whether the name is held by another guild or reserved by the deployment, deliberately, so which of the two it was cannot be read off the reply. So the console mirrorshas_slug_shape, says which of the three typing mistakes was made while the reader is still in the field, and stops. It carries no copy ofRESERVED_SLUGS: a page that answered "that name is reserved" without a request would re-introduce, one layer up, exactly the distinction the API collapsed.oauthClient.spec.tsasserts the reserved words are absent from the source of both checking functions, so the copy cannot creep back in.The half-configured state is named rather than hidden. Between registering a client and supplying its secret, the guild's link answers exactly as a name nobody has ever registered — same status, same body. That is the design working, not a fault, and it is the state an administrator is in between step 2 and step 3 of §6.2.12. It gets its own heading, its own colour and a sentence saying so, because a link drawn as "configured" is a link somebody hands out before it works.
Nothing on the sign-in path performs a lookup. The public page makes no request at all:
useApiis not even stubbed in its tests, so a component that grew a lookup would throw on mount. Two tests assert the stronger property directly — the page renders byte for byte alike foracme, forzzzzzzzzand forACME, with only the echoed slug differing. The middleware's allowlist is deliberately looser than the slug rule for the same reason: one that sent a malformed name to the ordinary sign-in page and a registered one to the guild page would be a one-request oracle for which organisations use this service, readable by anybody with a browser and no session.The page says what a guild's client does not govern. Linking a Discord account with
/linkstays on the environment-configured client permanently.apiholds the master key andlinkdoes not —charts/sturnus/templates/_helpers.tplrefuses to render it onto that component at all — solinkcannot unwrap a guild's secret and must not be given the ability to. That asymmetry is the architecture rather than a gap in it, and an interface implying otherwise would be promising the one thing the deployment is built to prevent. The sentence lives in~/utils/oauthClientbeside the rest of the contract, so removing it means deleting an argument.Every decision is in a pure module and tested without mounting anything.
~/utils/oauthClientholds what a slug may be, what a provider address may be, what the link is, what state it is in, what may be done to the credential, and what is wrong with a draft; the page is layout and request plumbing. Nine of its exports are prefixed (clientDraftBody,clientSecretState, …) because Nuxt auto-imports all ofapp/utilsand those names already belonged to~/utils/exportTargets— a collision the build resolves silently and the reader does not.Two smaller choices worth naming. The provider is stated, not chosen:
outlineis the only value the API accepts, and a dropdown with one row asks a question with one answer — but the field still travels in the draft, so a registration this console does not understand is not quietly rewritten to Outline by somebody saving a change of client id. Andredirect_uriis a checkbox plus a box that only exists when it is ticked, because""andnullare the same value in a text field and different values in this API.What it deliberately refuses to do
GET /guildsshows and what every other admin page already shows the same person. There is no list of registered slugs anywhere in this change.Acmeis refused rather than lowercased, and a base URL with whitespace around it is refused rather than trimmed — matchingis_valid_slug, and for its reason: a value rewritten on the way into the table is a value the administrator does not recognise in the link they handed out. The one exception is the client id, trimmed because it is pasted out of another application and nobody reads it back off a screen.ExportTargetSecret. The two controls answer different routes with different bodies — this one clears with aDELETEwhere that one clears with{"secret": null}— and one component parameterised over both is a component whose reader has to check which API they are looking at.Pure Tailwind, no stylesheet (
stylesheets.spec.tsenforces it), responsive, keyboard-operable, loading and error states on the page change and the fetch, every snowflake a string, and no credential logged, echoed or round-tripped anywhere. No Python changed, so nothing touchesobservability.fields.ALLOWED_FIELDS.One thing found on the way
docs/operations.md§6.2.12 writes the link an administrator hands out ashttps://…/api/auth/login?guild=acme, whiledomain/oauth_clients.pynames/g/{slug}/sign-in. This PR makes the second one real and the page prints that form; the API endpoint is unchanged and still works. The doc is worth reconciling in whichever branch touches it next.Checks
Measured on
eed19d7(v0.16.0) before and on this branch after, inconsole/:vitest run1719 → 1812 passed (93 new, 58 files) ·eslint .clean → clean ·nuxt typecheckclean → clean (the nine auto-import collision warnings the first draft produced are gone) ·nuxt buildsucceeds → succeeds, 3.97 MB → 4.09 MB.