← all branches

feat/one-api

needs attentionviewing older commit
1346cc0 · incrementalpre-PRreviewed 2026-07-27 20:15 UTC2H · 8M · 6L · 1I
The branch
Purpose
API-first key management for Batu's one-api initiative — public /v1/api-keys surface with security guarantees: no-amplification, cascade revocation, rotation lineage tracking, and admin-only credential enumeration.
Goal
Close all privilege-escalation and cascade-escape holes identified in round-1 review; sweep the never-throw contract across column-configuration and secret-configuration shells; wire the CI to provide Supabase credentials for the new integration test suite.
Sub-goals
  • SG-1: Fix rotation-laundering hole — replacement key is a child of the rotated key (not a new root)
  • SG-2: Move scope-escalation check for revoke from handler to decideRevoke (defence-in-depth)
  • SG-3: Re-read minting key liveness under advisory lock in createApiKeyShell (close TOCTOU)
  • SG-4: Gate list handler on admin role / key liveness (convergence with dashboard twin)
  • SG-5: Sweep never-throw contract in column-configuration shells (3 sites)
  • SG-6: CI — export Supabase credentials for public-v1 integration suite
The changes (whole branch)
What
decideRotate: createdByApiKeyId switched from existing.createdByApiKeyId (inherit parent) to existing.id (child of replaced). decideRevoke: granterScopes field added to command; scope-escalation check moved from handler to decision. decideCreate: granterKey state field added; re-read under advisory lock in shell; liveness checked at commit time. List handler: resolveCaller() gate added (admin+liveness). column-configuration shells: 3× throw→return err(). CI: supabase status -o env exports credentials dynamically.
Why
Round-1 review identified that inheriting the parent on rotation left root keys' replacements as new roots, defeating revocation as a leak-remediation tool. Handler-only scope checks leave every other shell caller unbounded. A TOCTOU between resolveCaller and the shell's commit allowed a revoked minting key to produce a child that escapes the cascade.
Areas
domains/core+18317apps/platform+8666.github/workflows+246.claude/rules+63
Blast
13 files in this delta, +299/-92 lines. Cumulative branch: 65 files, +32k/−250 (majority is migration snapshots). Security-critical paths: api-key.decisions.ts, api-key.shells.ts, api-keys.handler.ts.
security-sensitive api-key-management cascade-revocation no-amplification
typecheck· not run in this reviewtests· not run in this reviewcoderabbit· no .coderabbit.yaml

Findings · 17

security3

medium

Deleted minting key bypasses liveness guard — orphaned child escapes cascade

domains/core/src/api-key/api-key.shells.ts:133

In createApiKeyShell, if command.createdByApiKeyId is set but findById returns null (key hard-deleted between resolveCaller and the transaction), granterKey is null and decideCreate's isLiveKey guard is skipped. The inserted key carries a createdByApiKeyId pointing to a non-existent row, making it invisible to revokeDescendants' recursive CTE — a permanently orphaned child. Attack window is narrow (requires double race), but the fix is one line: if command.createdByApiKeyId != null && granterKey == null → return err(ApiKeyErrors.granterNotLive()).

low

Rotate shell does not re-read granter key liveness under the org advisory lock

domains/core/src/api-key/api-key.shells.ts:202

createApiKeyShell re-reads the granting key under acquireOrgCreateLock. rotateApiKeyShell takes no org advisory lock and performs no in-transaction re-read. OCC on the rotated key's row provides some protection, but the defense-in-depth gap vs createApiKeyShell is a consistency risk worth a follow-on issue.

low

scopesSatisfied([], []) is vacuously true — undocumented edge case

packages/shared-kernel/src/scopes.ts:52

scopesSatisfied uses required.every(), returning true when required is empty. Not a live vulnerability (decideCreate guards against empty-scope keys), but the vacuous-true behavior should be documented or asserted in case the empty-scopes guard is relaxed.

conventions5

high

mapRotateApiKeyError missing ApiKeyScopeEscalation — silent undefined return

apps/platform/src/api/mappers/api-key.mapper.ts:82

RotateApiKeyError now includes ApiKeyScopeEscalationError, but mapRotateApiKeyError has no case for it. The switch has no exhaustiveness guard, so the tag falls through and returns undefined — a scope-escalation error on rotate will produce an undefined response body rather than a 403. The public mapper handles it correctly; only the dashboard mapper is missing this case.

medium

decisions.ts imports type from queries.ts — dependency-direction violation

domains/core/src/api-key/api-key.decisions.ts:30

import type { InsertApiKeyValues } from ./api-key.queries pulls a type from an infrastructure file (queries.ts imports drizzle-orm). canonical-form.md states decisions import nothing from infrastructure. InsertApiKeyValues should be co-located in decisions.ts or type.ts to keep the dependency direction clean.

medium

Liveness predicate duplicated between handler and isLiveKey — drift risk

apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:116

resolveCaller() re-implements the isLiveKey predicate inline (status==='active' || rotating+rotationExpiresAt>now). isLiveKey is exported precisely to be the single authoritative predicate so 'issuance and exchange cannot drift apart' (its JSDoc). The handler should call isLiveKey(caller, new Date()).

low

revokeApiKeyShell .catch() swallows all errors without rethrowing unknown

domains/core/src/api-key/api-key.shells.ts:402

The canonical shell pattern scopes .catch() to specific errors and rethrows unknowns. The revoke .catch() maps every rejection to err(databaseError), potentially silencing non-transient failures. The comment explains the intent, but it deviates from the documented pattern.

info

deleteColumnConfigShell and setOrgDefaultShell lack transaction boundary .catch()

domains/core/src/column-configuration/column-configuration.shells.ts:315

Pre-existing: after the sweep fixing create/update/duplicate, these two shells still return raw transaction promises without a .catch(). If an outbox insert fails they throw rather than return Result<T,E>. Follow-on cleanup.

tests5

high

No test for active machine key listing — coverage gap in list authority suite

apps/platform/src/api/handlers/public-v1/__tests__/api-keys.authority.test.ts:112

The list describe block tests member→403, admin→200, revoked-machine→403. Missing: active machine key with api-keys:read scope → 200. Without a positive machine-key-list test, a future refactor that accidentally applies the admin guard to machine callers would pass the suite but break a legitimate caller.

medium

No test for rotating+null-expiry minting key in decideCreate

domains/core/src/api-key/__tests__/api-key.decisions.test.ts:161

The it.each covers ['revoked', null] and ['rotating', expired-date] but not { status: 'rotating', rotationExpiresAt: null }. isLiveKey checks `rotationExpiresAt !== null` before comparing, so null-expiry rotating key is dead — but untested. A future refactor could interpret null as 'no expiry = forever live'.

medium

Revoke 'forwards caller grant' test verifies forwarding only, not the enforcement 403

apps/platform/src/api/handlers/public-v1/__tests__/api-keys.authority.test.ts:251

The test checks command.granterScopes is forwarded but does NOT verify the handler returns 403 when caller scopes are insufficient against the target. Need a test: caller=['api-keys:write','bills:read'], target=['*'] → handler returns 403. What pins the wiring, not just the forwarding.

medium

No test for granterKey=null when createdByApiKeyId is non-null (hard-deleted parent)

domains/core/src/api-key/__tests__/api-key.decisions.test.ts:162

When createdByApiKeyId is supplied but the parent was hard-deleted, granterKey is null and decideCreate proceeds unchecked. Should be an explicit test clarifying the design decision — or the shell should return err(granterNotLive()) in this case.

low

updateColumnConfigShell non-unique-violation never-throw not covered

domains/core/src/column-configuration/__tests__/column-configuration.shells.integration.test.ts:627

The non-unique-violation databaseError test only exercises createColumnConfigShell. updateColumnConfigShell and duplicateColumnConfigShell have the same fix but no parallel test for non-23505 infrastructure errors.

improvement4

medium

Duplicate comment block in revokeApiKeyShell .catch()

domains/core/src/api-key/api-key.shells.ts

The catch handler has two overlapping comment blocks — the old 4-line block was not removed when the new fuller explanation was added. Delete the old block.

medium

CI NEXT_PUBLIC_* extraction spawns two extra supabase status invocations

.github/workflows/pr-checks.yml

SUPABASE_URL and SUPABASE_ANON_KEY are already in $GITHUB_ENV after the first invocation. NEXT_PUBLIC_* can simply be `echo "NEXT_PUBLIC_SUPABASE_URL=$SUPABASE_URL" >> "$GITHUB_ENV"` — no second subprocess needed. The current pattern uses two additional supabase status calls with throwaway single-char overrides.

low

isLiveKey not reused in findActiveByPrefix — drift surface for grace-window rule

domains/core/src/api-key/api-key.queries.ts

isLiveKey's JSDoc says it exists so issuance and exchange 'cannot drift apart', but findActiveByPrefix implements the same check as a SQL condition independently. Consider using isLiveKey as a post-query filter or adding a cross-reference comment in queries.ts.

low

ApiKeyGranterNotLive mapped to generic 'forbidden' in public mapper — not actionable for clients

apps/platform/src/api/mappers/public-v1/api-keys.mapper.ts

ApiKeyGranterNotLive maps to error:'forbidden' — a generic code that gives the client no way to distinguish 'your minting key was revoked between check and commit' from a generic permission denial. A dedicated code like 'granter_revoked' would be more actionable for API consumers debugging rotation races.

History · 47 commits

  1. 82bb5b9blockedincremental5H · 5M · 4L2026-08-12 01:48
  2. 90aa3d5needs attentionincremental1H · 5M · 3L2026-08-11 19:37
  3. 29d19a0needs attentionincremental1H · 5M · 9L2026-08-11 17:41
  4. 9bd8a0cneeds attentionfull0H · 5M · 9L2026-08-11 02:14
  5. 62ec3f7needs attentionincremental2H · 5M · 6L2026-08-10 22:51
  6. f93bca9needs attentionincremental2H · 5M · 8L2026-08-10 17:51
  7. 052db6fneeds attentionincremental1H · 3M · 4L2026-08-09 21:13
  8. 45699caneeds attentionincremental0H · 7M · 11L2026-08-09 17:44
  9. b843d8aneeds attentionincremental1H · 7M · 9L2026-08-09 04:05
  10. e1757b8needs attentionincremental0H · 3M · 6L2026-08-05 02:11
  11. 7a762faneeds attentionincremental2H · 5M · 5L2026-08-05 01:25
  12. 3300a60needs attentionincremental2H · 4M · 7L2026-08-04 19:06
  13. 0c8a7f5needs attentionincremental0H · 4M · 9L2026-08-04 18:15
  14. 345f42eneeds attentionincremental2H · 6M · 9L2026-08-04 17:28
  15. 8338a9aneeds attentionincremental5H · 14M · 14L2026-08-04 00:33
  16. 41be4c3needs attentionincremental0H · 5M · 7L2026-08-03 23:49
  17. 5ed593dneeds attentionincremental1H · 6M · 6L2026-08-03 21:32
  18. b333e25needs attentionincremental4H · 9M · 8L2026-08-03 21:00
  19. 5642cccneeds attentionincremental2H · 3M · 2L2026-08-03 20:17
  20. 73b0b39needs attentionincremental3H · 10M · 13L2026-07-31 18:29
  21. b19852eneeds attentionincremental0H · 1M · 5L2026-07-29 05:04
  22. 3845205needs attentionincremental3H · 6M · 4L2026-07-29 04:47
  23. eb8eb50needs attentionincremental0H · 1M · 2L2026-07-29 03:03
  24. f4720a3needs attentionincremental6H · 8M · 7L2026-07-29 02:54
  25. f8d341ablockedincremental2H · 2M · 5L2026-07-29 00:00
  26. a7f1a64needs attentionincremental2H · 8M · 8L2026-07-28 18:41
  27. 738b60bblockedincremental3H · 6M · 5L2026-07-28 00:46
  28. 2c248b6needs attentionincremental8H · 12M · 8L2026-07-27 23:23
  29. 1346cc0needs attentionincremental2H · 8M · 6L2026-07-27 20:15current
  30. 0716018needs attentionincremental2H · 11M · 12L2026-07-27 19:22
  31. 215cd2dneeds attentionincremental3H · 6M · 5L2026-07-27 17:04
  32. ec46958needs attentionincremental0H · 3M · 5L2026-07-27 16:51
  33. de7b337blockedincremental4H · 9M · 14L2026-07-27 06:36
  34. b1bb9c0needs attentionincremental1H · 2M · 4L2026-07-27 05:09
  35. 4701d11needs attentionincremental0H · 4M · 3L2026-07-27 04:44
  36. e1626c4needs attentionincremental3H · 9M · 10L2026-07-27 03:21
  37. 195f198needs attentionincremental3H · 3M · 3L2026-07-25 01:22
  38. 42c7358safeincremental0H · 0M · 0L2026-07-22 20:46
  39. 85b9018needs attentionincremental0H · 1M · 6L2026-07-21 23:51
  40. a7b2a9aneeds attentionincremental0H · 9M · 12L2026-07-21 18:49
  41. c2ee0daneeds attentionincremental4H · 7M · 7L2026-07-21 02:17
  42. e8ffa5eneeds attentionincremental4H · 7M · 5L2026-07-21 01:33
  43. a2d2a54needs attentionincremental2H · 7M · 3L2026-07-21 00:51
  44. 576fbd6needs attentionfull1H · 6M · 7L2026-07-21 00:35
  45. d3465e8needs attentionincremental1H · 7M · 10L2026-07-21 00:23
  46. dc794a7needs attentionincremental0H · 5M · 5L2026-07-20 23:46
  47. 9082773needs attentionfull1H · 3M · 3L2026-07-20 23:13