Skip to content

feat(identity): handles Wave A1 — owners namespace + claim API (migration 0022) - #184

Merged
unforced merged 2 commits into
mainfrom
handles-a1
Jul 17, 2026
Merged

feat(identity): handles Wave A1 — owners namespace + claim API (migration 0022)#184
unforced merged 2 commits into
mainfrom
handles-a1

Conversation

@unforced

Copy link
Copy Markdown
Contributor

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

  • Migration 0022 (workers/identity/migrations/0022_owners.sql) — ⚠️ prod-migration-bearing:
    • 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_owner partial unique index (WHERE owner_id IS NOT NULL) — ONE handle per account at the DB.
    • Follows 0020's idempotence posture (plain statements; the D1 migration runner never re-applies).
  • src/handles.tsHANDLE_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), and claimHandle (validate → reserved → claim-once → atomic INSERT owners + UPDATE users; UNIQUE race → HandleTakenError, mirroring VaultNameTakenError at vaults.ts:177).
  • Bearer endpoints (src/account-api.ts, wired in index.ts):
    • GET /account/handle (read) → { handle, suggested }
    • GET /account/handle/check?handle= (read) → { available, reason? }
    • POST /account/handle (admin) → claim-once; 409 handle_already_set if already set
    • /account/summary gains additive handle: string|null. Nothing else on the wire changes.
  • users.User gains ownerId (additive) so requireAccount's loaded user carries the handle-owner pointer.

Gates

  • workers/identity typecheck: clean
  • workers/identity vitest: 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).
  • root bun run typecheck: clean · root bun test: 153 pass / 0 fail
  • New coverage: test/migration-0022.test.ts (owners constraints + partial index), test/handles.test.ts (validation table, reserved, suggestion, claim-once, the race), test/account-api.test.ts additions (all 3 endpoints + scope enforcement + the summary field). Synthetic data only.

Judgment calls (for the reviewer)

  • The handle RE strictens the ratified charset to forbid leading/trailing hyphens (GitHub does the same) — one line to relax if unwanted.
  • 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. They still fail the claim; the reserved set lists them as defense-in-depth if the shape rule ever loosens.
  • /account/handle/check stays behind the account bearer per the plan, though availability is public info (GitHub-style).
  • claimHandle pre-checks owner_id then guards the batch UPDATE with owner_id IS NULL; a same-user concurrent claim of the same handle is caught cleanly by owners.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

unforced and others added 2 commits July 17, 2026 08:22
…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
@unforced
unforced merged commit dd74bbf into main Jul 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant