registry: claim an operator-made account through a sign-in so it can enrol a second factor #276

Closed
opened 2026-10-09 17:02:33 +00:00 by orion · 6 comments
Collaborator

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, else 403; 404 unknown account; 409 if the account is not claimable or no provider is configured): 201 {account, url, expires}. Event create-claim (subject the account, no detail: no token, no digest).

Redeeming. GET /account/claim?token= shows a page (checks the claim, consumes nothing; one generic 403 page 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 column sign_in_flows.claim, added by migrate() like tokens.factor). In finishSignIn a 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 a 409 that 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's POST /api/1/tokens for an account that has a way to sign in (an identity or a passkey) but no second factor is a 403 ("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 (expires 3600 s ahead, url under REGISTRY_PUBLIC_URL); 403 for an account token and for no token; 404 for an unknown account; 409 for an account with an identity or a passkey (also one made by a provider sign-up); 409 with no provider configured. A second call replaces the first: the first link then fails like an unknown one.
  • The database holds only the SHA-256 of the token; the token appears in no event, log line or other response (a test reads events and the claim row).
  • GET /account/claim?token= with a live claim answers 200 with a button per configured provider, consumes nothing (two GETs both work) and sets Referrer-Policy: no-referrer, Cache-Control: no-store and the account page's CSP with the providers' origins in form-action; unknown, used or expired token and missing token are the same 403 page.
  • Through a stand-in provider (as 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, 303 to /account), deletes the claim, logs claim-account with provider and subject, and the same identity then signs in to that account. A second redeem of the same link is a 403.
  • Refusals, each once: a wrong or used 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 a 409 and 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 a 409 and no second identity is linked.
  • A claim flow never produces a sign-up: a new identity is linked to the claimed account or refused, never parked in sign_ups; an ordinary flow with an unknown identity still goes to the sign-up.
  • After a claim the person enrols an authenticator app on the account page (/account/totp/new and add, tests/totp-account.test.trb style) and the account then has a factor: a first token of the account asks for Torb-Code on owners calls (the #274 rule, asserted again in the test).
  • The operator's POST /api/1/tokens for a claimed account without a factor is a 403; 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.
  • A database from before this change migrates (sign_in_flows.claim added, claims table 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/registry and torb test tools/registry/tests green natively and in the VM; existing tests otherwise unchanged and green.
  • Docs: 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.md step 6 (hand the person a claim link, they enrol a factor) with the curl for the claim call; skills/ regenerated, pdisc restamped.

Seed impact

None, checked: TorbScript in tools/registry (registry.trb, api.trb, account.trb, database.trb) on existing std and 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 by migrate(). 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, and docs(registry): ... if separate). Depends on #274 (landed as 584d266). Parent: #16. Security-relevant: architect review of the diff (implementer opus/high).

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](https://git.torb.dev/torbscript/language/src/commit/a183078a/docs/design/RELEASE.md) (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, else `403`; `404` unknown account; `409` if the account is not claimable or no provider is configured): `201 {account, url, expires}`. Event `create-claim` (subject the account, no detail: no token, no digest). **Redeeming.** `GET /account/claim?token=` shows a page (checks the claim, consumes nothing; one generic `403` page 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 column `sign_in_flows.claim`, added by `migrate()` like `tokens.factor`). In `finishSignIn` a 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 a `409` that 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's `POST /api/1/tokens` for an account that has a way to sign in (an identity or a passkey) but no second factor is a `403` ("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 (`expires` 3600 s ahead, `url` under `REGISTRY_PUBLIC_URL`); `403` for an account token and for no token; `404` for an unknown account; `409` for an account with an identity or a passkey (also one made by a provider sign-up); `409` with no provider configured. A second call replaces the first: the first link then fails like an unknown one. - [ ] The database holds only the SHA-256 of the token; the token appears in no event, log line or other response (a test reads `events` and the claim row). - [ ] `GET /account/claim?token=` with a live claim answers `200` with a button per configured provider, consumes nothing (two GETs both work) and sets `Referrer-Policy: no-referrer`, `Cache-Control: no-store` and the account page's CSP with the providers' origins in `form-action`; unknown, used or expired token and missing token are the same `403` page. - [ ] Through a stand-in provider (as `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, `303` to `/account`), deletes the claim, logs `claim-account` with provider and subject, and the same identity then signs in to that account. A second redeem of the same link is a `403`. - [ ] Refusals, each once: a wrong or used `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 a `409` and 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 a `409` and no second identity is linked. - [ ] A claim flow never produces a sign-up: a new identity is linked to the claimed account or refused, never parked in `sign_ups`; an ordinary flow with an unknown identity still goes to the sign-up. - [ ] After a claim the person enrols an authenticator app on the account page (`/account/totp/new` and `add`, `tests/totp-account.test.trb` style) and the account then has a factor: a first token of the account asks for `Torb-Code` on owners calls (the #274 rule, asserted again in the test). - [ ] The operator's `POST /api/1/tokens` for a claimed account without a factor is a `403`; 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. - [ ] A database from before this change migrates (`sign_in_flows.claim` added, `claims` table 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/registry` and `torb test tools/registry/tests` green natively and in the VM; existing tests otherwise unchanged and green. - [ ] Docs: `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.md` step 6 (hand the person a claim link, they enrol a factor) with the `curl` for the claim call; `skills/` regenerated, pdisc restamped. ## Seed impact None, checked: TorbScript in `tools/registry` (`registry.trb`, `api.trb`, `account.trb`, `database.trb`) on existing `std` and 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 by `migrate()`. 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`, and `docs(registry): ...` if separate). Depends on #274 (landed as 584d266). Parent: #16. Security-relevant: architect review of the diff (implementer opus/high).
Author
Collaborator

[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] 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
Author
Collaborator

[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] 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)
Author
Collaborator

[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):

  1. Claim token: 256-bit random, stored as SHA-256, 3600 s, one live per account (a new one replaces the old). 3600 s because the link crosses a human channel; the existing flows live 600 s (sign-in), 900 s (sign-up) and 300 s (step-up), but they are clicked within a minute. An unread link just expires and is re-issued. 256 bits needs no attempt limit.
  2. Claimable = no identity and no passkey at all, so every operator-made account and never an account a person used: the operator cannot take over a sign-up-made account. Otherwise 409.
  3. Redeem = the existing provider flow with the claim's digest on the flow (new column sign_in_flows.claim, migrate() like tokens.factor; new table claims). Claim row deletion (must change 1 row), claimable re-check and identity insert are one transaction; an identity linked elsewhere is a 409 that leaves the claim usable. A claim flow never reaches the sign-up path. GET /account/claim only shows the page (no consume), POST begins the flow; the token only travels in the URL once, the page is no-referrer/no-store.
  4. "Claiming ends the bootstrap latitude", made concrete: claiming creates no factor and revokes no token (so the releasing.md bootstrap order keeps working), but unbound operator tokens are now only for an account that cannot sign in: the operator's POST /api/1/tokens for an account with a way to sign in and no factor becomes a 403. Existing unbound tokens run to expiry (30 days at most), owners calls need a code once a factor exists (#274).
  5. Passkey claim dropped: it is "sign-up through a passkey" (a registration without a session), still under "Not built yet". Claiming through a provider gives a session, from which a passkey is added as today.
  6. Seed impact none (TorbScript in tools/registry on existing helpers; one table + one column by migrate()); 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; label ready set. Worker usage: this refine, one worker, no sub-spawns.
    next: implement. Claim released.
[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): 1. **Claim token**: 256-bit random, stored as SHA-256, 3600 s, one live per account (a new one replaces the old). 3600 s because the link crosses a human channel; the existing flows live 600 s (sign-in), 900 s (sign-up) and 300 s (step-up), but they are clicked within a minute. An unread link just expires and is re-issued. 256 bits needs no attempt limit. 2. **Claimable** = no identity and no passkey at all, so every operator-made account and never an account a person used: the operator cannot take over a sign-up-made account. Otherwise `409`. 3. **Redeem** = the existing provider flow with the claim's digest on the flow (new column `sign_in_flows.claim`, `migrate()` like `tokens.factor`; new table `claims`). Claim row deletion (must change 1 row), claimable re-check and identity insert are one transaction; an identity linked elsewhere is a `409` that leaves the claim usable. A claim flow never reaches the sign-up path. `GET /account/claim` only shows the page (no consume), POST begins the flow; the token only travels in the URL once, the page is no-referrer/no-store. 4. **"Claiming ends the bootstrap latitude"**, made concrete: claiming creates no factor and revokes no token (so the releasing.md bootstrap order keeps working), but unbound operator tokens are now only for an account that cannot sign in: the operator's `POST /api/1/tokens` for an account with a way to sign in and no factor becomes a `403`. Existing unbound tokens run to expiry (30 days at most), owners calls need a code once a factor exists (#274). 5. **Passkey claim dropped**: it is "sign-up through a passkey" (a registration without a session), still under "Not built yet". Claiming through a provider gives a session, from which a passkey is added as today. 6. **Seed impact** none (TorbScript in `tools/registry` on existing helpers; one table + one column by `migrate()`); 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; label `ready` set. Worker usage: this refine, one worker, no sub-spawns. next: implement. Claim released.
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-10T04:30Z

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-10T04:30Z
Author
Collaborator

[loop:torbscript] implement: PR #282 open, CI green (run 670) on d0a18a52 after a formatting fix of tools/registry/tests/claim.test.trb (tier A format --check). Commits: 726f384 feat(registry), 37e6d4d docs(registry), d0a18a5 test(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] implement: PR #282 open, CI green (run 670) on d0a18a52 after a formatting fix of tools/registry/tests/claim.test.trb (tier A format --check). Commits: 726f384 feat(registry), 37e6d4d docs(registry), d0a18a5 test(registry). Claim released. Tokens: worker run aborted after the push; fix inline by the tick. next: waiting (merge gate review of PR #282)
argus closed this issue 2026-10-09 23:49:18 +00:00
Author
Collaborator

[loop:torbscript] close: PR #282 approved and merged by argus at 2026-10-09T23:49Z as 765a6218; the issue closed with it. next: none

[loop:torbscript] close: PR #282 approved and merged by argus at 2026-10-09T23:49Z as 765a6218; the issue closed with it. next: none
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#276
No description provided.