feat/one-api
needs attentionviewing older commita7f1a64 · incrementalpre-PRreviewed 2026-07-28 18:41 UTC2H · 8M · 8L · 5I- Purpose
- Unify Batu's public API surface — one contract tree, one credential per org, one auth gate
- Goal
- Ship a production-ready one-credential API model: master key minted at org creation, reveal/rotate/revoke from dashboard, no scoped-key complexity until customer demand warrants it
- Sub-goals
- W1 (done): RouteMeta gate, route inventory, IDOR harness
- W2 (done): Service keys as api_keys (kind discriminator), /v1/auth/token unification, internal mount dual-accept, issueMaster repair path
- Scope removal: strip dormant scoped-key machinery to prevent it reading as a shipped feature
- Revocation reversibility: issueMasterApiKeyShell lets orgs recover from a revoked master
- What
- Two commits: (1) refactor removes scopes from RouteMeta/JWT/gate/decisions — withMetaGate now asks only 'what credential CLASS is this'; (2) fix adds issueMasterApiKeyShell (repair path for orgs with no active master) + migration 0069 promotes oldest active key to master for pre-existing orgs
- Why
- Dormant scope machinery reads as a shipped feature, invites drift, and adds complexity with no current customer value. Revocation was one-way (a single click could permanently brick an org's API access); the repair path makes it survivable.
- Areas
- domains/core/src/api-key+800−120apps/platform/src/api+650−180apps/platform/src/__tests__+490−10packages/api/src+320−180packages/database+210−20.claude/rules+95−60docs/security+40−0
- Blast
- 65 files in incremental review; ~2600 adds / ~570 dels across api-key domain, platform handlers, contracts, middleware, and DB schema. Core credential machinery — high-stakes but well-tested at integration level.
Findings · 26
correctness3
Revoke endpoint description says 'not reversible' — now factually wrong
apps/platform/src/api/contracts/public-v1/api-keys.contract.ts:99
The public revoke description ships to /developers docs and states revocation is not reversible. The fix(core) commit explicitly makes it reversible via issueMasterApiKeyShell. A customer who revokes and expects permanence has the wrong mental model; a customer who revokes by mistake needs to know recovery exists.
Stale module-level comment in public-v1 api-keys.handler.ts describes removed scopes claim
apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:17
Comment says the meta gate checked 'api-keys:read / api-keys:write' scopes. Scope gates were removed. Could lead a future developer to believe a scope check exists that does not.
createApiKeyShell is exported dead code — no production caller after issueMaster change
domains/core/src/api-key/api-key.shells.ts:107
Dashboard POST /organizations/:orgId/api-keys now calls issueMasterApiKeyShell instead of createApiKeyShell. No production caller remains. Keeping it exported risks a future developer using it for the 'named key creation' flow that is explicitly prohibited in the one-credential model.
security4
decideRotate/decideRevoke master guard uses === false — null isMaster bypasses it
domains/core/src/api-key/api-key.decisions.ts:267
The master-only guard is `command.granterIsMaster === false`. If isMaster were ever null on a machine key row (DB migration, direct manipulation), the check returns false (null !== false) and the master guard is silently bypassed. Currently safe because the DB default is false, but the type is boolean|null and the asymmetry is a latent trap. Recommend: check !== true && !== null, or document the invariant in resolveCaller.
Scope removal: no privilege escalation — machine keys always had ['*'] grant
withMetaGate now checks only credential class (machine/session/service). No narrower grant ever existed in production; removing the always-vacuously-true check changes nothing at runtime. The gate is still fail-closed on missing metadata.
Migration 0069 service-key exclusion is double-guarded
packages/database/drizzle/0069_backfill_master_api_keys.sql
Explicit org_id IS NOT NULL filter AND the api_keys_master_is_org_scoped CHECK constraint both prevent service key promotion. Robust.
issueMaster IDOR protection: requireOrgAccess resolves org from authenticated membership, not raw path param
apps/platform/src/api/handlers/api-keys.handler.ts
Handler uses requireOrgAccess(database, profileId, params.orgId, 'owner') which resolves org.id from the authenticated profile's membership. Org A cannot issue a master for org B.
conventions6
Stale JSDoc in public-v1 api-keys.handler.ts — describes removed scopes model
apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:16
Lines 16–19 describe 'api-keys:read / api-keys:write' scope enforcement and decideCreate's no-amplification rule. Both were removed in 738b60b8. The comment makes the refactor look reversible and will mislead the next reader.
resolveCaller JSDoc describes removed granterScopes field
apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:88
The JSDoc above resolveCaller says 'granterScopes is NULL for a session on purpose'. The field granterScopes no longer exists in the return type; the function returns { ok, parentKeyId, isMaster }. The comment should document isMaster semantics instead.
route-meta.ts file comment still says gate enforces 'auth + scopes'
packages/api/src/meta/route-meta.ts:7
The file-level comment reads: 'withPublicApiAuth's meta gate enforces auth + scopes at the mount'. RouteMeta.scopes was removed in 738b60b8. Should read 'auth at the mount'.
api-key.api-types.ts CreateApiKeyRequest comment still mentions 'optional scopes'
packages/api/src/types/api-key.api-types.ts:63
JSDoc says body carries 'name (and optional scopes)'. scopes was removed from CreateApiKeyCommand. Should say 'only name'.
decideRotate comment retains 'a key always satisfies its own scopes' — scope model gone
domains/core/src/api-key/api-key.decisions.ts:291
'Trivially allowed — a key always satisfies its own scopes' references the removed scope model. The underlying lineage-chain argument is valid but the parenthetical is now orphaned noise.
issueMasterApiKeyShell omits pure-decision function — accepted deviation, not documented
domains/core/src/api-key/api-key.master.ts:171
FCIS canonical form requires a pure decideIssueMaster. The rule is omitted because state must be re-read inside the transaction. This is the sanctioned escape hatch per domain-patterns.md, but the reason is not documented in the shell. A future reader may add the function thinking the shell is incomplete.
tests7
buildKey fixtures missing required fields — isMaster, secretArn, kind
domains/core/src/api-key/__tests__/api-key.shells.test.ts:85
The buildKey base object in both shells.test.ts and decisions.test.ts is missing required ApiKey fields (isMaster, secretArn, kind). TypeScript should reject the return site as ApiKey. Tests that exercise isMaster-dependent behavior get silent undefined instead of false, making granterKey.isMaster === false checks subtly unsafe.
No unit test for issueMasterApiKeyShell orchestration (compensation, race re-check, null-insert)
domains/core/src/api-key/__tests__/api-key.shells.test.ts
issueMasterApiKeyShell has integration coverage but zero mocked-unit tests. The three-phase structure (pre-check → prepareMasterApiKey → advisory lock → re-read → insert) has untested paths: discardMasterApiKeySecret compensation on failure, the 'committed err' race path, ApiKeyDatabaseError on null insert, and the unique-violation → ApiKeyMasterExists mapping.
No authority test for POST /organizations/:orgId/api-keys — admin→owner gate tightened silently
apps/platform/src/api/handlers/api-keys.handler.ts:83
The handler now calls requireOrgAccess(..., 'owner') but previously required only 'admin'. No test asserts that an admin (non-owner) gets 403. This is the surface that hands out a live credential — the missing regression test is the highest-priority gap.
buildKey missing createdByApiKeyId default — === null assertions vacuously pass on undefined
domains/core/src/api-key/__tests__/api-key.decisions.test.ts:49
buildKey base object omits createdByApiKeyId (required string|null). Tests asserting createdByApiKeyId !== null may pass vacuously because the field is undefined rather than null.
Migration 0069 backfill edge cases untested: promotion, service-key exclusion, idempotency
packages/database/drizzle/0069_backfill_master_api_keys.sql
The SQL is idiomatic and idempotent but the three edge cases — an org with one active machine key (should be promoted), a service key (org_id NULL, must be excluded), and a re-run on an already-promoted org — have no test assertions. These are migration invariants that can only be tested via direct SQL seeding.
IDOR harness correctly omits dashboard issueMaster route (not yet on public-v1)
apps/platform/src/__tests__/integration/public-v1-idor.test.ts
The issueMaster endpoint is on the internal dashboard contract (not yet converged to /v1). IDOR omission is architecturally correct. Cross-org guard is protected by requireOrgAccess which is tested via existing infrastructure.
route-meta.guard.test.ts correctly updated — no stale scope assertions
apps/platform/src/api/contracts/__tests__/route-meta.guard.test.ts
Guard test updated with 'Per-route scopes were removed 2026-07-28 — class is the whole check now'. All four assertions are scope-agnostic and remain valid. Count pin (>=48) satisfied with 51 routes.
improvement6
Dead 'POST /v1/api-keys — issue one' line + scope model doc in public-v1 handler module doc
apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:5
Module doc still lists 'POST /v1/api-keys — issue one (secret returned once)' (line 5) and describes no-amplification scope grants (lines 17–20). Neither exists after 738b60b8. The handler exports exactly three handlers (list/rotate/revoke).
Stale 'W2 deliverable — until it lands' note about RouteMeta in one-api.md
.claude/rules/one-api.md:432
RouteMeta shipped in W1 (commit 1c6325ac, marked done in the wave table). The 'W2 deliverable — until it lands, the registry seed is scripts/one-api/route-inventory.json' parenthetical is permanently false and misleads anyone implementing a new route.
Mis-indented closing brace of eventData in insertMasterApiKey
domains/core/src/api-key/api-key.master.ts:145
Closing } of eventData object is at 8 spaces instead of 6, inconsistent with surrounding code. Formatter artifact from scope-field removal.
InsertApiKeyValues docstring lists 'scopes' as a shell-prepared value — field was removed
domains/core/src/api-key/api-key.queries.ts:189
Comment says 'hashedSecret, prefix, scopes, etc.' — scopes is no longer in InsertApiKeyValues. Minor but misleads someone reading to understand what the shell must prepare.
decideRotate 'satisfies its own scopes' comment is orphaned noise after scope removal
domains/core/src/api-key/api-key.decisions.ts:291
The parenthetical 'a key always satisfies its own scopes' references removed scope model. The underlying lineage argument is valid — remove only the parenthetical.
VESTIGIAL api_keys.scopes rationale duplicated between api-key.type.ts and api-patterns.md
domains/core/src/api-key/api-key.type.ts:80
api-patterns.md is the SSOT for the VESTIGIAL note. The multi-line rationale in the type file could just point to that doc. Minor deduplication opportunity.
History · 47 commits
- 82bb5b9blockedincremental5H · 5M · 4L2026-08-12 01:48
- 90aa3d5needs attentionincremental1H · 5M · 3L2026-08-11 19:37
- 29d19a0needs attentionincremental1H · 5M · 9L2026-08-11 17:41
- 9bd8a0cneeds attentionfull0H · 5M · 9L2026-08-11 02:14
- 62ec3f7needs attentionincremental2H · 5M · 6L2026-08-10 22:51
- f93bca9needs attentionincremental2H · 5M · 8L2026-08-10 17:51
- 052db6fneeds attentionincremental1H · 3M · 4L2026-08-09 21:13
- 45699caneeds attentionincremental0H · 7M · 11L2026-08-09 17:44
- b843d8aneeds attentionincremental1H · 7M · 9L2026-08-09 04:05
- e1757b8needs attentionincremental0H · 3M · 6L2026-08-05 02:11
- 7a762faneeds attentionincremental2H · 5M · 5L2026-08-05 01:25
- 3300a60needs attentionincremental2H · 4M · 7L2026-08-04 19:06
- 0c8a7f5needs attentionincremental0H · 4M · 9L2026-08-04 18:15
- 345f42eneeds attentionincremental2H · 6M · 9L2026-08-04 17:28
- 8338a9aneeds attentionincremental5H · 14M · 14L2026-08-04 00:33
- 41be4c3needs attentionincremental0H · 5M · 7L2026-08-03 23:49
- 5ed593dneeds attentionincremental1H · 6M · 6L2026-08-03 21:32
- b333e25needs attentionincremental4H · 9M · 8L2026-08-03 21:00
- 5642cccneeds attentionincremental2H · 3M · 2L2026-08-03 20:17
- 73b0b39needs attentionincremental3H · 10M · 13L2026-07-31 18:29
- b19852eneeds attentionincremental0H · 1M · 5L2026-07-29 05:04
- 3845205needs attentionincremental3H · 6M · 4L2026-07-29 04:47
- eb8eb50needs attentionincremental0H · 1M · 2L2026-07-29 03:03
- f4720a3needs attentionincremental6H · 8M · 7L2026-07-29 02:54
- f8d341ablockedincremental2H · 2M · 5L2026-07-29 00:00
- a7f1a64needs attentionincremental2H · 8M · 8L2026-07-28 18:41current
- 738b60bblockedincremental3H · 6M · 5L2026-07-28 00:46
- 2c248b6needs attentionincremental8H · 12M · 8L2026-07-27 23:23
- 1346cc0needs attentionincremental2H · 8M · 6L2026-07-27 20:15
- 0716018needs attentionincremental2H · 11M · 12L2026-07-27 19:22
- 215cd2dneeds attentionincremental3H · 6M · 5L2026-07-27 17:04
- ec46958needs attentionincremental0H · 3M · 5L2026-07-27 16:51
- de7b337blockedincremental4H · 9M · 14L2026-07-27 06:36
- b1bb9c0needs attentionincremental1H · 2M · 4L2026-07-27 05:09
- 4701d11needs attentionincremental0H · 4M · 3L2026-07-27 04:44
- e1626c4needs attentionincremental3H · 9M · 10L2026-07-27 03:21
- 195f198needs attentionincremental3H · 3M · 3L2026-07-25 01:22
- 42c7358safeincremental0H · 0M · 0L2026-07-22 20:46
- 85b9018needs attentionincremental0H · 1M · 6L2026-07-21 23:51
- a7b2a9aneeds attentionincremental0H · 9M · 12L2026-07-21 18:49
- c2ee0daneeds attentionincremental4H · 7M · 7L2026-07-21 02:17
- e8ffa5eneeds attentionincremental4H · 7M · 5L2026-07-21 01:33
- a2d2a54needs attentionincremental2H · 7M · 3L2026-07-21 00:51
- 576fbd6needs attentionfull1H · 6M · 7L2026-07-21 00:35
- d3465e8needs attentionincremental1H · 7M · 10L2026-07-21 00:23
- dc794a7needs attentionincremental0H · 5M · 5L2026-07-20 23:46
- 9082773needs attentionfull1H · 3M · 3L2026-07-20 23:13