feat(identity): handles Wave A1 — owners namespace + claim API (migration 0022) - #184
Merged
Conversation
…tion 0022)
The GitHub owner-model groundwork (ratified 2026-07-16): an `owners` table (the
one namespace users + orgs share), `users.owner_id`, the one-handle-per-account
partial unique index, and the claim API on the account bearer surface. Dark and
inert — nothing serves or advertises a `/u/<handle>` URL yet (Waves B–D), so an
account without a handle is byte-for-byte unaffected.
- migration 0022: owners(owner_id PK, handle UNIQUE, kind CHECK user|org,
claimed_at) + users.owner_id (nullable) + idx_users_owner partial unique
index; the 0020 idempotence posture (rely on the D1 migration runner).
- src/handles.ts: HANDLE_RE ([a-z0-9-]{3,30}, no leading/trailing hyphen — a
strictening within the ratified charset, one line to relax), the reserved
list, suggestHandleFromEmail, claimHandle (validate → reserved → claim-once →
atomic INSERT owners + UPDATE users; UNIQUE race → HandleTakenError, the
VaultNameTakenError pattern).
- account-api.ts (Bearer): GET /account/handle (read) → {handle, suggested};
GET /account/handle/check (read) → {available, reason?}; POST /account/handle
(admin) → claim-once, 409 handle_already_set. /account/summary gains an
additive `handle: string|null`. Nothing else on the wire changes.
Gates: identity typecheck clean, identity vitest 874 passed / 0 failed (run in
batches — the local workerd pool exhausts loopback sockets running all 35 files
at once), root typecheck clean, root bun test 153 pass / 0 fail.
Judgment calls flagged for review: the handle RE strictens the ratified charset
to forbid leading/trailing hyphens (GitHub does the same); short reserved words
(u/me/my/id) are shape-rejected as `invalid` before the reserved check since the
RE's 3-char floor precedes it; /account/handle/check stays behind the account
bearer per the plan though availability is public info.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
…n/lying-200 window Reviewer blocker on #184: same-account concurrent claims of two DIFFERENT handles → request 2's UNCONDITIONAL `INSERT INTO owners` succeeded while its guarded UPDATE matched 0 rows, orphaning handle #2 (squatted by nobody) AND returning a lying 200 {handle:#2} while /account/handle reported #1 — the sole bypass of one-handle-per-account. Fix (in the existing batch): the owners INSERT is now CONDITIONAL — `... SELECT ?,?,'user',? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id=? AND owner_id IS NOT NULL)` — and `results[0].meta.changes === 0` throws HandleAlreadySetError. The prior read-then-write pre-check is removed: it WAS the check-then-act window that raced; the conditional INSERT is now the single, race-free, DB-level claim-once authority (a D1 batch is one serialized transaction, so an INSERT that wrote its row always pairs with the UPDATE that lands the pointer). The other-account same-handle race is unchanged (owners.handle UNIQUE → HandleTakenError). Test added: concurrent distinct-handle claims by one account → exactly one fulfilled, one HandleAlreadySetError, exactly one owners row (no orphan), and the account resolves to the winning handle (DB agrees with the return — no lie). Gates: identity typecheck clean, identity vitest 875 passed / 0 failed, root typecheck clean, root bun test 153 pass / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Handles Wave A1 (of the ratified handles/sharing plan) — the GitHub owner-model groundwork on the identity worker. Dark and inert: nothing serves or advertises a
/u/<handle>URL yet (Waves B–D), so an account without a handle is byte-for-byte unaffected. One additive field is the only wire change.What ships
workers/identity/migrations/0022_owners.sql) —owners(owner_id PK, handle TEXT NOT NULL UNIQUE, kind CHECK(user|org), claimed_at)— the one namespace users + orgs share.users.owner_id(nullable — claiming is optional + Settings-initiated).idx_users_ownerpartial unique index (WHERE owner_id IS NOT NULL) — ONE handle per account at the DB.src/handles.ts—HANDLE_RE([a-z0-9-]{3,30}, no leading/trailing hyphen), the reserved list (plan §5 union),suggestHandleFromEmail(pure; always returns a valid non-reserved handle), andclaimHandle(validate → reserved → claim-once → atomicINSERT owners+UPDATE users; UNIQUE race →HandleTakenError, mirroringVaultNameTakenErroratvaults.ts:177).src/account-api.ts, wired inindex.ts):GET /account/handle(read) →{ handle, suggested }GET /account/handle/check?handle=(read) →{ available, reason? }POST /account/handle(admin) → claim-once;409 handle_already_setif already set/account/summarygains additivehandle: string|null. Nothing else on the wire changes.users.UsergainsownerId(additive) sorequireAccount's loaded user carries the handle-owner pointer.Gates
workers/identitytypecheck: cleanworkers/identityvitest: 874 passed / 0 failed — run in batches; the local workerd pool exhausts loopback sockets running all 35 files at once (worker-startup crashes, zero assertion failures; the 3 changed files run clean together at 108).bun run typecheck: clean · rootbun test: 153 pass / 0 failtest/migration-0022.test.ts(owners constraints + partial index),test/handles.test.ts(validation table, reserved, suggestion, claim-once, the race),test/account-api.test.tsadditions (all 3 endpoints + scope enforcement + the summary field). Synthetic data only.Judgment calls (for the reviewer)
u,me,my,id) are shape-rejected asinvalidbefore the reserved check, since the RE's 3-char floor precedes it. They still fail the claim; the reserved set lists them as defense-in-depth if the shape rule ever loosens./account/handle/checkstays behind the account bearer per the plan, though availability is public info (GitHub-style).claimHandlepre-checksowner_idthen guards the batch UPDATE withowner_id IS NULL; a same-user concurrent claim of the same handle is caught cleanly byowners.handle UNIQUE(the realistic double-submit). Documented in the function.Merges AFTER any in-flight PR; rebased onto
origin/main(rc.100) and bumped to rc.101.🤖 Generated with Claude Code
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB