registry: claim an operator-made account through a sign-in so it can enrol a second factor #276
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up split from the refine of #274 (parent #16). Refined; decisions recorded in the comment of 2026-10-09 (checked at
a183078).Goal
An account the operator made (
POST /api/1/accounts) has no linked identity, so it cannot sign in, so it can never enrol a second factor: its tokens stay unbound for good. #274 therefore keeps the bootstrap path (the first token, and operator tokens for an account without a factor, stay unbound and capped to 30 days) but cannot close it. This issue lets such an account be claimed: the operator makes a single-use claim link, the person opens it, signs in through one of the configured providers, and that identity is linked to the existing account and a session starts. From that session the person enrols a factor (authenticator app or passkey) like any other account. A claimed account then no longer gets unbound operator tokens.Design
Design record: RELEASE.md 7.1 Owners (a person's owner is the account; several providers) and 7.6 "A stolen token" (the unbound-token rule this closes); ROADMAP: the registry's sign-in point (parent #16). The record reserves nothing for the owner; the choices below are internal and recorded in the loop comment.
Claim token. 256 bits of the system's randomness (
newSecret()), kept only as its SHA-256, valid 3600 s, one live claim per account (a new one replaces the old in the same transaction), expired rows deleted on each new claim and at start. Shown once, as a URL<REGISTRY_PUBLIC_URL>/account/claim?token=<secret>.Who can be claimed. An account with no identity and no passkey at all (flagged or not): this is every operator-made account and no account a person ever signed in to, so the operator can never take over a person's account. Everything else is a
409.Operator call.
POST /api/1/accounts/<name>/claim(operator only, else403;404unknown account;409if the account is not claimable or no provider is configured):201 {account, url, expires}. Eventcreate-claim(subject the account, no detail: no token, no digest).Redeeming.
GET /account/claim?token=shows a page (checks the claim, consumes nothing; one generic403page for unknown, used or expired) with a button per configured provider;POST /account/claim {token, provider}(same-site checked, no session needed) begins the normal provider flow (state, PKCE, cookie__Host-sign-in) with the claim's digest stored on the flow (new columnsign_in_flows.claim, added bymigrate()liketokens.factor). InfinishSignIna flow with a claim never goes to sign-in or sign-up: after the usual checks, in one transaction the claim row is deleted (it must change one row, so of two redeems only one wins), the account is re-checked as claimable, and the identity is inserted; then a session starts and the page is the account page. An identity already linked to any account is a409that leaves the claim unused, so the person can try another provider within the hour. The claim page and its refusals carry the existing no-referrer, no-store, no-script headers; the token is never in an event, a log line or a response other than the one that made it.Bootstrap latitude. Claiming links an identity; it does not create a factor and revokes no token, so the bootstrap steps of
docs/contributing/releasing.md(create account, organisation, trusted publishing, first publish) keep working in any order. What ends is the reason for unbound tokens: the operator'sPOST /api/1/tokensfor an account that has a way to sign in (an identity or a passkey) but no second factor is a403("enrol a factor on the account page and mint your own tokens"). The unbound path stays only for an account that cannot sign in. Existing unbound tokens run to their expiry (30 days at most); their owners calls need a code as soon as the account has a factor (#274).Passkey claim is out of scope (the original text said "or registers a passkey"): a passkey registration needs a session today, and a claim without a provider is "signing up through a passkey", listed under "Not built yet". Claiming through a provider gives the person a session, from which a passkey is added as for any account.
Acceptance criteria
POST /api/1/accounts/<name>/claim:201 {account, url, expires}for the operator on an operator-made account (expires3600 s ahead,urlunderREGISTRY_PUBLIC_URL);403for an account token and for no token;404for an unknown account;409for an account with an identity or a passkey (also one made by a provider sign-up);409with no provider configured. A second call replaces the first: the first link then fails like an unknown one.eventsand the claim row).GET /account/claim?token=with a live claim answers200with a button per configured provider, consumes nothing (two GETs both work) and setsReferrer-Policy: no-referrer,Cache-Control: no-storeand the account page's CSP with the providers' origins inform-action; unknown, used or expired token and missing token are the same403page.tests/sign-in.test.trb): the full claim links the provider's subject to the existing account (owner, memberships and tokens unchanged), starts a session (cookie,303to/account), deletes the claim, logsclaim-accountwith provider and subject, and the same identity then signs in to that account. A second redeem of the same link is a403.state, the flow's cookie from another browser, a wrong PKCE verifier (existing flow checks still apply to claim flows); a claim that expired between the page and the callback; an identity already linked to another account is a409and the claim still works with another identity; two redeems at once leave exactly one identity and one session; an account that gained an identity between the page and the callback is refused with a409and no second identity is linked.sign_ups; an ordinary flow with an unknown identity still goes to the sign-up./account/totp/newandadd,tests/totp-account.test.trbstyle) and the account then has a factor: a first token of the account asks forTorb-Codeon owners calls (the #274 rule, asserted again in the test).POST /api/1/tokensfor a claimed account without a factor is a403; for an account that cannot sign in it still works (30 days at most,unbound); for an account with a factor it is bound as before. Existing tests that mint operator tokens for provider-made accounts without a factor are adjusted.sign_in_flows.claimadded,claimstable created) with its accounts, tokens, flows and events untouched; the new column and table are covered by the existing migration test pattern.torb check tools/registryandtorb test tools/registry/testsgreen natively and in the VM; existing tests otherwise unchanged and green.tools/registry/README.md(call table rows for the claim call and/account/claim, the design bullet on #274's unbound tokens and "Not built yet" paragraph), RELEASE.md 7.6 "A stolen token" row and the write-service Tokens row (unbound only for an account that cannot sign in; claim link),docs/contributing/releasing.mdstep 6 (hand the person a claim link, they enrol a factor) with thecurlfor the claim call;skills/regenerated, pdisc restamped.Seed impact
None, checked: TorbScript in
tools/registry(registry.trb,api.trb,account.trb,database.trb) on existingstdand existing registry helpers (newSecret,digestOf,waysToSignIn, the flow code); no syntax change, no native, no driver flag, no std name the compiler looks up, no new std surface. Schema: one new table and one new column, both bymigrate(). No seed refresh, so no two-commit rule.Size and dependencies
One PR, one or two Conventional Commits (
feat(registry): claim an operator-made account through a sign-in, anddocs(registry): ...if separate). Depends on #274 (landed as584d266). Parent: #16. Security-relevant: architect review of the diff (implementer opus/high).[loop:torbscript] intake: in scope (author orion, label loop, split from #274 / parent #16), not a duplicate, no collision with a design record. Needs refine (claim token design, binding to the provider flow, interaction with the #274 bootstrap latitude) and an architect review of the diff. next: waiting (refine queued, ready buffer full: #4, #163, #164, #223 are ready) until 2026-10-10T04:00:00Z
[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-10T00:30Z (ready buffer holds only 3 ready issues, 2 of them waiting on a seed, so refine is allowed; the earlier "buffer full" marker was stale)
[loop:torbscript] refine: decided by the loop (internal design, no owner question: nothing public, no cost, no external service, no design-record reservation; the registry's only callers are the operator and the account's own scripts):
409.sign_in_flows.claim,migrate()liketokens.factor; new tableclaims). Claim row deletion (must change 1 row), claimable re-check and identity insert are one transaction; an identity linked elsewhere is a409that leaves the claim usable. A claim flow never reaches the sign-up path.GET /account/claimonly shows the page (no consume), POST begins the flow; the token only travels in the URL once, the page is no-referrer/no-store.POST /api/1/tokensfor an account with a way to sign in and no factor becomes a403. Existing unbound tokens run to expiry (30 days at most), owners calls need a code once a factor exists (#274).tools/registryon existing helpers; one table + one column bymigrate()); one PR, one or two Conventional Commits; architect review of the diff. Body rewritten with goal, testable acceptance criteria, design record points, seed impact and size; labelreadyset. Worker usage: this refine, one worker, no sub-spawns.next: implement. Claim released.
[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-10T04:30Z
torb deprecate: flag a whole package as deprecated #279torb owner --step-up: approve owner changes with a passkey #280[loop:torbscript] implement: PR #282 open, CI green (run 670) on
d0a18a52after a formatting fix of tools/registry/tests/claim.test.trb (tier A format --check). Commits:726f384feat(registry),37e6d4ddocs(registry),d0a18a5test(registry). Claim released. Tokens: worker run aborted after the push; fix inline by the tick.next: waiting (merge gate review of PR #282)
[loop:torbscript] close: PR #282 approved and merged by argus at 2026-10-09T23:49Z as
765a6218; the issue closed with it. next: none