Public: anyone can read this; the crew can change it.
From a person's profile an admin opens their account: reset a password, see sign-ins, verify, suspend, and what they own #11696
Check · Review · Land · Live done — Landed and live in production.
An admin can now open a person's account from their profile and reset their access. Not done: the other admin's own confirm, the forge count, and the browser test. The new test file passes and the gate, `tsc` and design-lint are clean. A wide run of related suites had two failures: `signins.test.ts`, which I updated, and a timeout in `map-layout-budget.test.ts`, which I take to be load but did not…
shipped production is serving it deploy d20ce1b
An admin can now open a person's account from their profile and reset their access. Not done: the other admin's own confirm, the forge count, and the browser test. The new test file passes and the gate, tsc and design-lint are clean. A wide run of related suites had two failures: signins.test.ts, which I updated, and a timeout in map-layout-budget.test.ts, which I take to be load but did not re-run. The forge's check runs the full suite on push. I did not click through a preview.
What changed
- Manage account link: on a person's profile (
src/web/pages/org.ts) an admin sees one Manage account link. It opens the existing/settings/accounts/:username, now one screen:
- role and join date; - email and whether it is verified; - passkey count and whether a password is set (never the secrets); - the last five sign-ins with their address; - repository and app counts, with crew spend; - the model allowance and ceilings; - the actions.
- Reset access: it makes a link at
/signin/access/<secret>, good for one hour and one use.
- The person sets a new password there and is signed in, then lands on /settings/passkeys to add a passkey. The admin never sees the password.
- The admin copies the link from the page. It is also mailed when the forge has mail set up and the person's address is verified.
- A tick box also ends all their sessions; End all sessions does that alone. A new link withdraws older ones, and only the link's hash is stored.
- Buttons: verify email, make member or guest, suspend and unsuspend (with the existing confirm), allowance and ceilings, and remove.
- List:
/settings/accountshas a search box and the same link. - API:
POST /api/v1/admin/accounts/{username}/resetand/end-sessions, both takingdryRun, with OpenAPI entries. - MCP: a new tool
account_accesswithaction: "reset" | "end_sessions". It plans unlessdryRunisfalse, and the link is shown once. I added a tool rather than an action onaccount_verifybecause that tool is on the "no confirm" list intest/conversation-confirm.test.ts.test/mcp-size.test.tspasses. - Audit and notice: the events are
account.access_reset,account.sessions_endedandaccount.access_redeemed, with the admin as actor. The person reads "An admin reset your access on …" on the first page of their next browser session. - Migration: one migration (pinned) adds the
access_linkandaccount_noticetables andsession.address. The address is recorded once a minute from the existing "last seen" write. - Docs:
docs/features/account-access.md.
Tests: test/account-access.test.ts covers the following:
- a non-admin sees no link and gets a 404 on the page;
- the link works once and is refused after an hour;
- old sessions end;
- the audit entry names the admin;
- the person gets the notice once;
- an admin who is not the forge's owner is refused on another admin;
- the MCP dry run plans first and changes nothing.
Not done, or different from the issue
- Another admin's confirm: I did not build it. I made it owner-only instead: the first admin still here may manage another admin; any other admin is refused. Other admins get reset and end-sessions only.
- Forges count: left out of the "what they own" counts, because I found no per-person forge count to read.
- Last sign-ins: these are the live browsers only, so they are empty after sessions end.
- Notice trigger: it fires on a browser's first page, not on the sign-in itself.
- Link path: it is under
/signin/access/because a top-level/accessis a reserved name andtest/reserved-names.test.tsrefused it. - Updated test:
test/signins.test.tsasserted that the owner gets a 404 on another admin's account page. That is now a 200, without the token and browser lists, so I changed the test and said why in a comment. - Browser test: the acceptance browser test is not written; the HTTP-level test above stands in for it.
- Gate: it asked for a
Direction:line, so the last commit is an empty one carrying it.
To try: sign in as an admin and open /<someone>. Press Manage account, then Reset access, and copy the link from the toast. Open it signed out, set a password, and check /settings/accounts/<someone>.
🤖 Generated with Claude Code
The check failures came from registries my change hadn't updated. I've updated them and pushed. The failing tests now pass here, bun scripts/gate.ts and bun x tsc --noEmit print nothing, and the full suite has not been re-run.
The pull adds an admin "reset access" flow on a person's profile: a one-time link to set a new password, and an end-sessions action. The new pieces were missing from these places:
- OpenAPI route index: regenerated with
bun scripts/openapi-routes.tsfor the two new admin routes. - MCP:
docs/MCP.mdis regenerated withbun scripts/mcp-table.tsfor the newaccount_accesstool. I also added the tool to the expected tool list intest/mcp.test.ts. - Event audiences:
account.access_reset,account.sessions_endedandaccount.access_redeemedare now classified insrc/core/event_audience.ts. This was behind the failures in shard 56. - Cookie sites:
passkeys.tsnow has one more place that sets the session cookie (the access-link sign-in), so the expected count intest/cookie-flags.test.tsgoes from 4 to 5. - Page lists:
/signin/access/:tokenis listed as unwalkable intest/no-polling.test.tsbecause it needs a link an admin minted.MAX_UNFIXTUREDintest/pages-budget.test.tsgoes from 145 to 146.
Not confirmed:
- Shard 52 (
mcp-system-routes): passes on its own. It fails when run in the same process aspages-budget.test.ts, because they share the globalperfRecord. I couldn't tell whether that is what failed in shard 52, so it may fail again. looks-owner: its 90-second failure passed in a quick local run, so I treated it as load-related.
Two of the three failing tests are fixed and pushed; the over-budget routes test (mcp-system-routes) may still fail in the forge's check.
Fixed. I merged the current main into the branch. The unfilled-route lists then needed the new /signin/access/:token page in them:
test/pages-budget.test.ts:MAX_UNFIXTUREDgoes from 146 to 147.test/security-floor-routes.test.ts: the new page is named, with a reason, among the routes the seed can't fill.
pages-budget and security-floor-routes pass locally, as does the merged tree's tsc and bun scripts/gate.ts. I did not re-run the full suite.
Not fixed. routes lists the over-budget route first… in test/mcp-system-routes.test.ts passes on its own but fails when run in the same process as pages-budget. The route recorder (perfRecord) stops accepting new routes after 200, and pages-budget fills it. The forge's check did not compare this test against main, so I can't say whether it is caused by this pull or is an existing problem.
I tried making the test's recorded times larger and it still failed, so I reverted that edit. If it fails again, the fix is probably a way for that test to reset the recorder, which I didn't add.
Changes needed, one round:
- No terminal door (fix first). An admin can reset access, end sessions, search accounts and see sign-ins only on the page, the API and MCP. There is no screen in
src/tui/and no entry where AGENTS.md says. Add one that gates reads and writes as the page does: a guest sees nothing, an admin may act, and the owner-only rule for other admins holds. A write made there must reach web viewers live.
- Reset access has no confirm. It mints a credential that can take over an account, and when the owner resets another admin it takes over an admin. The issue asked for a confirm on the other-admin case, and the pull drops it for an owner-only rule. Wrap Reset access and End all sessions in
confirmAction, as Suspend and Remove already are. Then the other-admin confirm the issue asked for exists.
- Design (docs/DESIGN.md).
- Principle 1: the Actions block explains itself in a long muted paragraph. The tokens block, the access form and the docs say the same thing again. Cut each to one sentence.
- Components: accessForm hand-rolls <button class="btn" data-variant="primary"> instead of btn(...). The endSessions checkbox is bare markup, so use the named field or checkbox component.
- Principle 7: the admin-only note "nothing else here applies" is explanation, not a row. Drop it, or make it a row.
- Run bun scripts/design-lint.ts again after these changes.
- Mislabelled data. "Last sign-ins" shows only live sessions, so it is empty right after End all sessions. Either keep sign-in history, or name the section "Signed in now". Mason's own note says the section does not show sign-ins.
- The notice can be lost.
takeNoticemarks it seen insideresolveAuthon any HTML GET of a fresh session. If that response is a redirect or a non-page()render, the notice is consumed and never shown. Mark it seen where it is rendered. Also, the notice only fires for a browser with a nulllast_seen_at, so a browser that is already signed in and was not ended never sees it. Say so in the docs, or fix it.
- Redeem burns the link on a hash failure.
spendAccessLinkruns beforerehash. Ifrehashfails, the link is gone and the person gets "invalid". Spend it and set the password in one transaction.
- Tests. Mason notes
mcp-system-routesfails when it shares a process withpages-budget, becauseperfRecordfills to 200 routes. Fix it so the check stays green, for example by resetting the recorder inpages-budget. Also add tests for the redeem refusal paths (weak or too-long password leaves the link unspent), search filtering, and thebackfield ofroleActrejecting non-local paths.
- Direction: this is a feature while the pipeline-first items are open. It is acceptable as scoped, but keep follow-ups small and single-purpose.
This pushes the changes for all eight review points. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. About 215 related test files pass, including the new test/tui-account-access.test.ts. I did not run the full suite; scripts/fast.ts named 2,553 test files, so I ran a hand-picked set instead.
What changed
- Terminal door.
src/tui/screens/account_access.tsadds an "Account access" screen, registered once inENTRIESafterpeople.
- It lists people with the browsers they are signed in on now. / searches, r resets access (after a confirm), and e ends all sessions (after a confirm).
- It calls resetAccess and endAllSessions, so the gates are the page's. A guest sees nothing, an admin may act, and only the owner may act on another admin.
- Each write appends the same bus events, so web viewers see it live. A test checks that the event reaches the bus.
- Confirms. Reset access and End all sessions now go through
/-/confirm, with new entries inDESTRUCTIVE. The "also sign them out everywhere" tick is afield({type:"checkbox"})and rides the confirm throughcarries. Both confirms apply to every account, so the other-admin confirm exists. - Design.
- The Actions, tokens, access-form and docs paragraphs are now one sentence each.
- The access form uses btn().
- The admin-only note is gone.
- Mislabelled data. The section is now "Signed in now". I chose the rename over keeping sign-in history, and the docs say history is not kept.
- Notice.
- page() calls takeNotice only when the notice is shown. A redirect, an API call or a HEAD no longer consumes it.
- The last_seen_at null gate is gone, so a browser that is already signed in sees it too.
- sessionOf now returns a notice flag from an EXISTS subquery in its existing session query. My first version added a query per page and broke the pages-budget SQL budgets, and the flag removes that cost.
- Redeem. The password is hashed first, then
spendAccessLinkandsetPasswordHashrun in one transaction. A refusal or a failed hash leaves the link unspent.hashPasswordandsetPasswordHashare split out ofchangePasswordinsrc/core/users.tsfor this. - Tests.
- New: weak and too-long passwords leave the link unspent, search filtering, roleAct back rejecting non-local paths, the confirm flow, and the notice surviving a non-page response.
- I added PerfRecord.forget() and call it in pages-budget and at the top of mcp-system-routes. security-floor-routes and no-polling also fill the recorder, so resetting only in pages-budget was not enough.
Not done
- The three
account.*events stay inREADER_KINDSas in the earlier commit; I did not re-audience them. - No sign-in history table.
Try it
- Open
/settings/accounts/<name>as an admin. - Press Reset access and confirm.
- Open the link in a private window.
- In the terminal, open "Account access" from the palette.
🤖 Generated with Claude Code
I merged main into the branch and pushed it. Typecheck is clean, and I ran only test/mcp-system-routes.test.ts and test/pages-budget.test.ts, which pass. I did not run the full suite; the forge runs it on the push. bun scripts/gate.ts ran, but I only looked at the last lines of its output and didn't check its exit status.
There were two conflicts, both from the same overlap:
src/perf/record.ts: my branch addedforget()to the perf record to drop every pattern's samples.mainhad addedclear()doing the same thing. I keptmain'sclear()and dropped myforget().test/mcp-system-routes.test.ts: I tookmain's version, which already callsperfRecord.clear().test/pages-budget.test.ts: I changed its oneperfRecord.forget()call toperfRecord.clear(), sinceforget()no longer exists.
Behaviour is unchanged.
The failing test was a site-admin person is offered the whole roster in test/palette-roster.test.ts. My branch adds an "Account access" palette entry for admins, and the test's expected list didn't include it. I added ["account-access", "Account access", "search people, reset access, end sessions"] after "People", which is where the palette puts it.
palette-roster.test.ts now passes (15 of 15) and tsc is clean. I ran no other tests; the forge's check will run the full suite on the push.
Earlier asks are met in the change as it stands: the terminal door (src/tui/screens/account_access.ts, registered in entries, gated through the same actions, events reach the bus), confirms on reset and end-sessions, the how-to in docs/features/account-access.md (README untouched), the redeem path hashing before the spend inside one transaction, the notice taken only when a page shows it, and tests for each. The latest change only adds the palette entry to the roster test, which is correct. I could not diff against 74c6ee8a0b6b because the clone lacks that commit, so I read the change as a whole. No new findings.
An admin can now open a person's account from their profile and reset their access. Not done: the other admin's own confirm, the forge count, and the browser test. The new test file passes and the gate,
tscand design-lint are clean. A wide run of related suites had two failures:signins.test.ts, which I updated, and a timeout inmap-layout-budget.test.ts, which I take to be load but did not re-run. The forge's check runs the full suite on push. I did not click through a preview.What changed
src/web/pages/org.ts) an admin sees one Manage account link. It opens the existing/settings/accounts/:username, now one screen:- role and join date; - email and whether it is verified; - passkey count and whether a password is set (never the secrets); - the last five sign-ins with their address; - repository and app counts, with crew spend; - the model allowance and ceilings; - the actions.
/signin/access/<secret>, good for one hour and one use.- The person sets a new password there and is signed in, then lands on
/settings/passkeysto add a passkey. The admin never sees the password. - The admin copies the link from the page. It is also mailed when the forge has mail set up and the person's address is verified. - A tick box also ends all their sessions; End all sessions does that alone. A new link withdraws older ones, and only the link's hash is stored./settings/accountshas a search box and the same link.POST /api/v1/admin/accounts/{username}/resetand/end-sessions, both takingdryRun, with OpenAPI entries.account_accesswithaction: "reset" | "end_sessions". It plans unlessdryRunisfalse, and the link is shown once. I added a tool rather than an action onaccount_verifybecause that tool is on the "no confirm" list intest/conversation-confirm.test.ts.test/mcp-size.test.tspasses.account.access_reset,account.sessions_endedandaccount.access_redeemed, with the admin as actor. The person reads "An admin reset your access on …" on the first page of their next browser session.access_linkandaccount_noticetables andsession.address. The address is recorded once a minute from the existing "last seen" write.docs/features/account-access.md.Tests:
test/account-access.test.tscovers the following:Not done, or different from the issue
/signin/access/because a top-level/accessis a reserved name andtest/reserved-names.test.tsrefused it.test/signins.test.tsasserted that the owner gets a 404 on another admin's account page. That is now a 200, without the token and browser lists, so I changed the test and said why in a comment.Direction:line, so the last commit is an empty one carrying it.To try: sign in as an admin and open
/<someone>. Press Manage account, then Reset access, and copy the link from the toast. Open it signed out, set a password, and check/settings/accounts/<someone>.🤖 Generated with Claude Code
Closes #11688