feat: seed keeper from labeled org invite on enrollment
Let an operator label an org-minted invite with a keeper handle at mint
time (POST /admin/invites body {"keeper"?}), persist it as invites.keeper_label,
and auto-seed the enrolling rook's keeper from that label when the invite is
spent at signup (upsertKeeper(did, label, 'invite')). Commons-only.
The seed runs off the response critical path via executionCtx.waitUntil in a
try/catch that logs {did, label} and never rethrows, so enrollment success is
never blocked or slowed by keeper metadata. Label is optional: an unlabeled
mint behaves exactly as before (keeper_label null, no seed).
- directory.ts: keeper_label column (INVITES_SCHEMA + additive migration via
the existing memoized PRAGMA guard, now covering both invite columns);
__resetInviteMigrationCache test hook; mintOrgInvite carries/returns the
label (first-write-wins on replay + race-loser read); upsertKeeper gains a
source param writing keeper_source; getInviteKeeperLabel read helper;
listInvites + InviteRecord expose keeper_label; finalizeTakedown nulls the
spent invite's label.
- worker.ts: POST /admin/invites parses/validates the optional keeper field
(trim, non-empty, <=512, non-string/malformed-JSON -> 400) and echoes
keeper_label; seedKeeperFromInvite waitUntil seed after finalizeInviteSpend.
- test/commons.test.ts: coverage for fresh-schema + real migration paths,
body validation, idempotent/race label carry, labeled/unlabeled seed,
non-fatal seed (upsertKeeper mocked to reject), operator override, rook-mint
no-seed, listing, and takedown scrub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyu45rFpJqMzvCvTfRwF7D
feat: invite minting (rook DPoP mint, org Access admin, /roost)
Add commons-only rook invite minting via POST /api/invites: DPoP auth resolves the rook, proof-of-life returns MintLocked before quota checks, lifetime quota returns QuotaExceeded, and an atomic conditional INSERT returns {token,url,remaining}.
Gate /admin/* with in-worker Cloudflare Access JWT verification using RS256 against the team JWKS with an in-memory cache, fail closed to 404 when Access bindings are missing, and expose org minting, quota config, and paginated invite listing routes.
Reconcile invites and invite_quotas to the COUNT-based schema with default quota seed 3, add GET /roost 302, keep the feature commons-only with reference-variant 404 coverage, and add tests for Access deny/fail-closed, quotas, pagination, and token generation.
Include incidental pre-existing tsc fixes in auth.ts and sequencer-do.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add project scaffold and SQLite storage layer
Bootstrap TypeScript/Hono/better-sqlite3/vitest project with multi-tenant
SqliteRepoStorage implementing @atproto/repo's RepoStorage interface.
- tsconfig.json, vitest.config.ts, .gitignore, README.md
- src/config.ts: env var parsing with validation
- src/db.ts: schema init for accounts, blocks, collections, blobs, firehose_events
- src/storage.ts: SqliteRepoStorage with account-scoped queries, transactional applyCommit, prev_data_cid tracking
- src/cbor-compat.ts: CBOR compat layer ported from cirrus
- src/index.ts: Hono health-check server
- test/storage.test.ts: 8 tests covering Repo.create, applyWrites, multi-tenant isolation, transaction atomicity
feat: seed keeper from labeled org invite on enrollment
Let an operator label an org-minted invite with a keeper handle at mint
time (POST /admin/invites body {"keeper"?}), persist it as invites.keeper_label,
and auto-seed the enrolling rook's keeper from that label when the invite is
spent at signup (upsertKeeper(did, label, 'invite')). Commons-only.
The seed runs off the response critical path via executionCtx.waitUntil in a
try/catch that logs {did, label} and never rethrows, so enrollment success is
never blocked or slowed by keeper metadata. Label is optional: an unlabeled
mint behaves exactly as before (keeper_label null, no seed).
- directory.ts: keeper_label column (INVITES_SCHEMA + additive migration via
the existing memoized PRAGMA guard, now covering both invite columns);
__resetInviteMigrationCache test hook; mintOrgInvite carries/returns the
label (first-write-wins on replay + race-loser read); upsertKeeper gains a
source param writing keeper_source; getInviteKeeperLabel read helper;
listInvites + InviteRecord expose keeper_label; finalizeTakedown nulls the
spent invite's label.
- worker.ts: POST /admin/invites parses/validates the optional keeper field
(trim, non-empty, <=512, non-string/malformed-JSON -> 400) and echoes
keeper_label; seedKeeperFromInvite waitUntil seed after finalizeInviteSpend.
- test/commons.test.ts: coverage for fresh-schema + real migration paths,
body validation, idempotent/race label carry, labeled/unlabeled seed,
non-fatal seed (upsertKeeper mocked to reject), operator override, rook-mint
no-seed, listing, and takedown scrub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyu45rFpJqMzvCvTfRwF7D
feat: handle acceptance policy at signup (reservation, blocklist, HandleTaken 409)
Evaluate submitted handles at POST /api/signup in a fixed order against the
lowercased single name label: single-label/syntax (InvalidHandle) -> reservation
of 1-2 char labels (HandleReserved) -> blocklist membership (HandleReserved) ->
availability (HandleTaken 409). Reservation and blocklist share one code path and
message so refusals reveal nothing about which rule matched.
The availability check is now a D1 SELECT (directory.handleExists, existence-only,
any row active-or-not) that runs before PLC DID creation and Account DO
provisioning, closing the orphaned-identity leak where a duplicate handle was only
caught at the final INSERT after an identity had already been minted. The
UNIQUE-constraint catch is kept as the concurrent-race backstop and its code is
renamed HandleAlreadyTaken -> HandleTaken.
The name label is lowercased at the boundary so PLC alsoKnownAs, the Account DO,
the D1 row, and the response all carry the lowercase handle. The blocklist lives
in src/handle-policy.ts (three reviewable sections, 249/53/63 = 365 entries) with
a test asserting complete per-section membership against an independent literal
fixture. The duplicate-handle integration test now asserts HandleTaken and proves
no PLC DID fetch or Account DO id is created on a rejected duplicate.
Note: `npx tsc --noEmit` still reports 22 pre-existing errors in out-of-scope
files (account-do.ts, auth.ts, sequencer-do.ts, and unrelated worker.ts paths);
this change introduces none and resolves one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: invite minting (rook DPoP mint, org Access admin, /roost)
Add commons-only rook invite minting via POST /api/invites: DPoP auth resolves the rook, proof-of-life returns MintLocked before quota checks, lifetime quota returns QuotaExceeded, and an atomic conditional INSERT returns {token,url,remaining}.
Gate /admin/* with in-worker Cloudflare Access JWT verification using RS256 against the team JWKS with an in-memory cache, fail closed to 404 when Access bindings are missing, and expose org minting, quota config, and paginated invite listing routes.
Reconcile invites and invite_quotas to the COUNT-based schema with default quota seed 3, add GET /roost 302, keep the feature commons-only with reference-variant 404 coverage, and add tests for Access deny/fail-closed, quotas, pagination, and token generation.
Include incidental pre-existing tsc fixes in auth.ts and sequencer-do.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: seed keeper from labeled org invite on enrollment
Let an operator label an org-minted invite with a keeper handle at mint
time (POST /admin/invites body {"keeper"?}), persist it as invites.keeper_label,
and auto-seed the enrolling rook's keeper from that label when the invite is
spent at signup (upsertKeeper(did, label, 'invite')). Commons-only.
The seed runs off the response critical path via executionCtx.waitUntil in a
try/catch that logs {did, label} and never rethrows, so enrollment success is
never blocked or slowed by keeper metadata. Label is optional: an unlabeled
mint behaves exactly as before (keeper_label null, no seed).
- directory.ts: keeper_label column (INVITES_SCHEMA + additive migration via
the existing memoized PRAGMA guard, now covering both invite columns);
__resetInviteMigrationCache test hook; mintOrgInvite carries/returns the
label (first-write-wins on replay + race-loser read); upsertKeeper gains a
source param writing keeper_source; getInviteKeeperLabel read helper;
listInvites + InviteRecord expose keeper_label; finalizeTakedown nulls the
spent invite's label.
- worker.ts: POST /admin/invites parses/validates the optional keeper field
(trim, non-empty, <=512, non-string/malformed-JSON -> 400) and echoes
keeper_label; seedKeeperFromInvite waitUntil seed after finalizeInviteSpend.
- test/commons.test.ts: coverage for fresh-schema + real migration paths,
body validation, idempotent/race label carry, labeled/unlabeled seed,
non-fatal seed (upsertKeeper mocked to reject), operator override, rook-mint
no-seed, listing, and takedown scrub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyu45rFpJqMzvCvTfRwF7D