--- title: "ADR 0021 — A person's account has its own page, and org Settings is for owners and admins" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:41:31.987Z" --- # ADR 0021 — A person's account has its own page, and org Settings is for owners and admins **Status** Accepted · **Date** 2026-09-16 · **Supersedes** nothing **Amends** `design/screens/settings.md` (passkeys and the read-only member views) and `design/screens/shell.md` (the account sheet). **Relates to** ADR 0009 (the permission ladder), ADR 0010 (a compensating control needs a way to see that it stopped), `docs/TENANCY.md` §4 (one person, one session, every org), `docs/LAUNCH_FLAGS.md` (`passkeys`), `apps/worker/src/auth/config.ts` and `apps/worker/src/auth/routes.ts`. ## Context The owner asked for three things in the 2026-09-16 fix wave: - a page where a person manages their own account; - passkeys off the org's Settings; - org Settings for the org's admins only. The product had no such page. Four facts made the gap worse than it looked: - **Nobody could change a password.** better-auth serves `/change-password`, and no screen called it. The endpoint had no per-caller bound, so a stolen session could guess the current password with no limit. A change that went through left no record. - **Nobody could change an email address.** better-auth refused `/change-email` because the configuration did not turn it on. - **Passkeys sat in org Settings.** A passkey belongs to a person. Its relying party is the root domain (`docs/TENANCY.md` §4), so one passkey works at every org. When somebody turned the `passkeys` flag on, the page came back as one org's tab. - **Every member saw org Settings.** Members read Org, Members, Billing and Danger zone pages that they could not change. The only role check in the rail read `session.user.role`, which is the instance-wide field. That field is `member` for every hosted account, so an owner check built on it was wrong for every hosted owner. ## Decision ### 1. `/account` is the person's page `/account`, labelled "Account", answers on the hosted apex, on an org's own address, and for a signed-in person who is not a member at this address. It reads only `/api/auth/*`, which the front-door fence leaves open. It reads no doc, so it does not touch invariant I7. The account menu opens it with the row "Account" (amended 2026-09-16: the row first said "Account settings"). The page has four sections: - **Profile.** The name. The editor moved here from the account sheet; there is one name editor. - **Email.** The current address as a value, and a form that asks for a new one (see §3). - **Password.** With a password: the current password, a new password, and "Sign out other sessions", on by default. Without one: "Email me a link to set a password". It sends the reset link that the sign-in page sends. better-auth's reset creates the password when there is none. - **Passkeys.** Only while `LAUNCH_FLAGS.passkeys` is on. `/settings/passkeys` is an alias of `/account`, so an old bookmark lands on the page that has them. ### 2. A password change is bounded and recorded - `/auth/change-password` and `/auth/change-email` spend the per-client credential bucket (30 a minute) and a new per-account bucket (`ACCOUNT_CHANGES_PER_MINUTE`, 10 a minute), keyed on the session's own user id. A request with no session spends no credential bucket. - `/auth/change-email` spends two more bounds, because its link goes to an address that the caller chooses: - the bucket of the new address, after the account's bucket. It is the per-mailbox bucket that sign-in spends (10 a minute), keyed on the digest of the normalised address. So one address gets at most ten links a minute, from any number of accounts. - the shared per-host bound (60 a minute), as the sign-in link has. The body names the address whose bucket the request makes, so the host bound caps how many buckets one minute can make. It also keeps the mail that one host sends for email changes under the cap of its sign-in links. - `/auth/change-password` makes no bucket that a caller can name, so it is not behind the per-host bound. - `password_changed` (`user_id`, `revoked_other_sessions`) and `password_change_refused` (`user_id`, `reason`, `attempts`) are `warn` lines. Neither carries a password or an address. `user_id` is the field name because the log redactor keeps an identifier-named field and removes a 32-character id anywhere else. - **A password change does not revoke agent connections.** A minted token, a Quick Connect link and an OAuth grant are credentials the person gave to a tool on purpose. "Sign out other sessions" ends browser sessions. Connect ends connections. ### 3. An email change is a request until the new mailbox confirms it - A confirmation link goes to the **new** address. A notice goes to the **current** address. The notice names both addresses, says that nothing has changed, and gives the fix: change the password with "Sign out other sessions" on. It goes whenever better-auth answers `200`, which includes an address that already has an account, so the old inbox is not an oracle. - The notice does not promise that the fix comes first. The person who asked may hold the new mailbox and open the link a few seconds later. So the notice also says how to get the address back: sign in with the new address and the password, then change the address back. A session alone cannot change the password, because a change asks for the current one. - Nothing changes until somebody opens the link. The page never says "changed" before then. better-auth answers the same whether or not it sent a link, so the page says what to do and adds "If {email} already has a kmem account, no link arrives." - **The link works only in a browser signed in to the account that asked.** better-auth alone signs a signed-out browser into the account and then changes its address. The gate in `hooks.before` refuses that case. It also refuses a browser signed in to another account. It sends the browser back to `/account` with a code that says what to do, and the link still works afterwards. When the same account opens the link a second time, the gate sends it to the page as if the change worked then. - **Every redirect of the gate stays on this origin.** The gate runs before better-auth checks `callbackURL`, and most of its redirects happen before anything checks the token's signature. So before each redirect it asks better-auth's own rule, `isTrustedOrigin` with relative paths allowed, and uses `/account` when the rule refuses. A rule written in the gate first let `/%09/evil.example` through: a browser removes the tab and opens another origin. - **A taken address.** When another account takes the new address after the request, the gate refuses the link with `EMAIL_CHANGE_ADDRESS_TAKEN`, and the page says so. Without this check, better-auth's write failed on the unique address index with a bare `500`. A sign-up in the short time between the gate's read and the write still ends in that failure. The code goes only to a browser that holds the account and opened the link from the new mailbox, so it tells a stranger nothing. - **Other sessions stay.** Invitations bound to the old address stay bound to the old address. - `email_change_requested` and `email_changed` are `warn` lines with `user_id` only. - kmem sends no other verification email. The gate refuses `/send-verification-email`, kmem sends nothing at sign-up or sign-in, and the gate refuses a verification link that changes no address. **How this meets the mailbox-proof rule.** `revokeCredentialsPredatingProof` runs when an account first proves its mailbox, because a stranger may have set a password and a passkey on an address that is not theirs. An email change is **not** that first proof, and the change revokes nothing: - The link opens only in the account's own session, so the person who proves the new mailbox already holds every credential the account has. There is nobody to start the account again for. - An account that had not proven its old address has proven its new one afterwards. The person who now controls the new address set its credentials. - No request can record a first proof without the revocation. On this path `user.update.before` stores the new address normalised and never sets `proofPending`. It holds a verification that changes no address at unproven. `apps/worker/test/auth/change-email.test.ts` walks each clause: - a proven account and an unproven one; - a signed-out browser and another account's browser; - a second open and a second change; - an address that another account has, at the request and at the open; - an invitation bound to the old address; - eleven hostile callbacks on every redirect of the gate; - the notice that the wrapper sends for every `200`, and the bounds on the new address and the host. ### 4. Org Settings is for owners and admins - **Amended 2026-09-16: org settings is in the org menu, not in the navigation.** The owner capped the phone's bottom bar at five cells. The owner also approved the same four destinations for every person: Docs, Ask, Review and Connect. The org menu shows "Org settings" and "Members and groups" only to a person whose **per-org** role carries `owner` or `admin` (`orgMenuRowsFor` in `apps/web/src/shell/screens.ts`). The first version of this decision drew a Settings row in the navigation for those people instead. - The shell reads that role at the org's own address, from `get-active-member-role?organizationSlug=`. On a host that serves none of the person's orgs, it reads the instance role, as `/api` does there. The shell keeps the last answer for the tab, so a reload draws the org menu's rows at once. - The navigation's rows no longer depend on the role, so the boot skeleton draws the same four rows for everybody. The first version kept the tab's last answer for the skeleton, and a member's first load in a tab drew five rows and then four. That cannot happen now. - The org menu shows no settings rows while the shell reads the role. When the read fails, the menu shows them. To hide org settings from an owner on a bad network is the worse mistake, and the server refuses a member's write either way. - **One role on a settings page.** The shell keeps the slug of the org it read the role in. The Org and Danger panels act on the shell's answer when that slug is their own org's. They ask the roster only while the shell reads, when its read failed, or on a host where the shell read the instance role. - A member who opens a settings address sees the shell's one forbidden state, with the words "Only org owners and admins can open Org settings", and "Go to Docs" and "Export docs". **Export is the exception.** A member may export the docs they can read (`routes/api/export.ts`), so `/settings/export` draws for a member, without the rail, and the account menu offers it on an org's own address as "Export docs". - The rule "owner or admin" is `managesMembers` in `apps/web/src/screens/team/roster.ts`, over `@signpost/core`'s role parser. The Members page and the shell use it. This change deletes the settings panels' private copy of the parser, and a source scan in `apps/web/test/shell/org-role.test.ts` refuses a second copy of the rule. This is information architecture, not access control. The server answers every settings call by its own rule (`mayEditOrganization`, the organization plugin's own checks), and this change neither widens nor narrows that. ### 5. Passkeys are not offered anywhere while their flag is off The sign-in intro, "What happens next", the Danger zone's password helper, the worker's check-inbox page and the credential `429` no longer name passkeys. A unit test gives the sign-in page a browser with WebAuthn and proves that the flag alone keeps the Passkey tab away. The server's passkey plugin stays mounted, so `/api/auth/passkey/*` still answers a direct caller; the flag hides the door, and the ceremony's failure is still undiagnosed. ## The alternatives that were rejected **Keep passkeys and the password in org Settings, and hide the rows from members.** Rejected because none of these facts belongs to an org. A person with two orgs would have two doors to one password, and a person with no org would have none. **Send a confirmation to the old address first, then a link to the new one** (better-auth's `sendChangeEmailConfirmation`). Rejected because it costs one more message and one more round trip, and the notice gives the old mailbox the same warning. The session requirement already means only the account's holder can finish a change. **Let the email-change link open in any browser, as better-auth does.** Rejected for two reasons. The link would sign the owner of the new mailbox, who may not have asked, in to somebody else's account. And a person who opens a link from a stranger would move the stranger's account onto their own address. **Treat an email change as a first proof and revoke everything.** Rejected because the person who proves the new mailbox is the person who holds the credentials. The revocation would sign the owner out of every device because they changed their own address. The contract also says that other sessions stay. **Hide Settings until the shell has the role, and draw nothing when the read fails.** Rejected because an owner on a flaky network would lose the only door to their org's settings. ## Consequences - A person can change their name, email and password from one page on every host. - Four new compensating events join `COMPENSATING_EVENTS` in `test/auth/accountability.test.ts`. - `/settings/passkeys` resolves to `/account`. The settings rail has five rows: Org, Members, Export, Billing and Danger zone. - Every person's navigation has four rows. An owner's or an admin's org menu has "Org settings" and "Members and groups", drawn once the role answers (amended 2026-09-16). - On an org host, the shell makes two more requests when the page loads: the org list and the role. The settings panels no longer ask the roster for the role when the shell has it. - A burst of sign-in requests at one host can hold back an email change at that host for up to a minute, because both spend the host's shared bound. Sign-in links have the same trade. - The existing `password_reset_completed` and `auth_mailbox_proven` lines still name their account in a field called `account`, and the log redactor removes that value. This ADR records the gap. This wave does not change those lines.