Skip to content

feat(console): let a guild bring its own sign-in without touching a deployment - #153

Open
TheMeinerLP wants to merge 1 commit into
mainfrom
feat/console-oauth-ui
Open

feat(console): let a guild bring its own sign-in without touching a deployment#153
TheMeinerLP wants to merge 1 commit into
mainfrom
feat/console-oauth-ui

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

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

#147 gave 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 from curl: the guild_oauth_client rows existed, five routes wrote them, and nothing in a browser ever had.

/admin/sign-in-link is 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-in is the second half, and it did not exist either. domain/oauth_clients.py has 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. ClientDraft carries 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 (PUT and DELETE on .../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_oauth answers 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 mirrors has_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 of RESERVED_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.ts asserts 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: useApi is 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 for acme, for zzzzzzzz and for ACME, 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 /link stays on the environment-configured client permanently. api holds the master key and link does not — charts/sturnus/templates/_helpers.tpl refuses to render it onto that component at all — so link cannot 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/oauthClient beside 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/oauthClient holds 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 of app/utils and 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: outline is 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. And redirect_uri is a checkbox plus a box that only exists when it is ticked, because "" and null are the same value in a text field and different values in this API.

What it deliberately refuses to do

  • No availability check, in any form. No button, no green tick, no debounced call as you type, no list of names in use. There is no endpoint that could serve one and there must not be.
  • No directory of guilds beyond what the viewer already has. The switcher shows what GET /guilds shows and what every other admin page already shows the same person. There is no list of registered slugs anywhere in this change.
  • No masked secret, no reveal, no "last four characters". Anything on screen standing in for the value would be a value, and it would have had to come from somewhere.
  • No expanded 409. "That sign-in name is not available" is the whole sentence. Saying whether it is held or reserved would undo the endpoint's discretion from outside it.
  • No normalisation. Acme is refused rather than lowercased, and a base URL with whitespace around it is refused rather than trimmed — matching is_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.
  • No shared component with ExportTargetSecret. The two controls answer different routes with different bodies — this one clears with a DELETE where 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.ts enforces 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 touches observability.fields.ALLOWED_FIELDS.

One thing found on the way

docs/operations.md §6.2.12 writes the link an administrator hands out as https://…/api/auth/login?guild=acme, while domain/oauth_clients.py names /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, in console/: vitest run 1719 → 1812 passed (93 new, 58 files) · eslint . clean → clean · nuxt typecheck clean → clean (the nine auto-import collision warnings the first draft produced are gone) · nuxt build succeeds → succeeds, 3.97 MB → 4.09 MB.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant