← all branches

feat/one-api

needs attentionviewing older commit
b1bb9c0 · incrementalpre-PRreviewed 2026-07-27 05:09 UTC1H · 2M · 4L · 2I
The branch
Purpose
Retire the static INTERNAL_API_KEY by making service credentials first-class API keys — same table, same token exchange, discriminated by a `kind` column (`machine` for customers, `service` for Batu's own backend callers).
Goal
W2 of the one-api.md roadmap: service keys mint via `/v1/auth/token`, the internal mount dual-accepts service tokens, and the meta-gate enforces per-route scopes for service actors.
Sub-goals
  • SG-1a: platform-scoped service actor — JWT, AuthContext, gate
  • SG-1b: ServiceKey entity folded into api_keys table with kind discriminator
  • SG-2: mint service tokens from the SAME /v1/auth/token endpoint
  • SG-3: internal mount dual-accepts service tokens
  • Round-1 review fixes: address panel findings
  • Round-2 review fixes: repair regression introduced by first pass
The changes (whole branch)
What
Round-2 incremental: (1) restructured createServiceKeyShell to chain .catch() outside the transaction callback (postgres.js rethrow fix); (2) replaced hardcoded API_KEY_FORMAT regex with CUSTOMER_KEY_SECRET_FORMAT constant from the module; (3) fixed error code mapping — ApiKeyServiceRotationUnsupported was emitting API_KEY_INVALID_NAME, now correctly API_KEY_SERVICE_ROTATION_UNSUPPORTED; (4) added createdByProfileId:null to service key create event; (5) added regression tests for service scope check; (6) tightened integration test assertions (code: '23514' vs generic .toThrow()); (7) expanded CI db-changes detection to include credential-path files.
Why
Previous review pass introduced a regression in createServiceKeyShell where an in-callback try-catch was returning a Result but postgres.js was still rethrowing — causing the unique-violation handler to be dead code and the shell to surface DB errors as uncaught exceptions. The error code mis-mapping and missing FORMAT constant were caught as latent drift bugs.
Areas
domains/core+58920packages/api+47545apps/platform+36537.claude/rules+654packages/database+533.github/workflows+262
Blast
31 files, +1464/-109 across 6 areas (excl. drizzle snapshot). Core area: domains/core api-key entity (shells, decisions, tests); packages/api middleware+auth; apps/platform auth-token handler + meta-gate + api-key mapper.
rotate contract missing 400 response declaration — confirmed gap no open PR yet — pre-PR branch
ci· No open PR — CI status not availablecoderabbit· No .coderabbit.yaml in repo

Findings · 9

correctness2

medium

createServiceKeyShell .catch() swallows programmer errors — rotateApiKeyShell rethrows them

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

The shell comment claims 'same pattern rotateApiKeyShell uses', but `rotateApiKeyShell`'s `.catch()` (line 261–268) has an explicit `throw e` for any exception that is NOT the `RotateInsertFailed` sentinel — it only converts known-safe errors to Results. The new `createServiceKeyShell .catch()` converts EVERY non-unique-violation error to `err(ApiKeyErrors.databaseError('insert'))`, including outbox-write bugs, null-pointer errors, and infrastructure panics. These become silent `databaseError` Results instead of propagating to the crash reporter. Fix: add `throw e` as the final else branch, parallel to `rotateApiKeyShell`.

low

API_KEY_FORMAT alias name is misleading — service-key OR-composition is implicit

apps/platform/src/api/handlers/public-v1/auth-token.handler.ts:69

The variable is named `API_KEY_FORMAT` but is assigned `CUSTOMER_KEY_SECRET_FORMAT`. The actual pre-auth check is `API_KEY_FORMAT.test(key) || SERVICE_KEY_SECRET_FORMAT.test(key)`, which is currently correct. But a future simplification to just `!API_KEY_FORMAT.test(key)` would silently reject all `sk_` service-key tokens with a 401. Rename to `CUSTOMER_KEY_FORMAT` or `CUSTOMER_KEY_REGEX` to make the OR-composition self-documenting and close the future-dev trap.

security3

low

Timing equalization gap: candidates-found-but-none-match path skips dummy hash verify

apps/platform/src/api/handlers/public-v1/auth-token.handler.ts:119

The DUMMY_HASH equalization fires only on the zero-candidates branch. When candidates are found but every `verifySecret` call fails (e.g. prefix collision with a different org's key), the handler falls through to `return UNAUTHORIZED_RESPONSE` without burning an equalizing scrypt hash. An attacker who learns a valid prefix can distinguish 'prefix exists but wrong secret' from 'prefix unknown' via a ~50ms timing delta. Low severity because: (a) the prefix is already visible to org admins, (b) cross-org prefix collision is cryptographically unlikely. Consider moving the equalization to cover both zero-candidates AND no-match paths.

low

Service key updateLastUsedAt errors silently swallowed — no logging unlike the customer-key path

apps/platform/src/api/handlers/public-v1/auth-token.handler.ts:136

The service-key path calls `apiKeyQueries.updateLastUsedAt(…).catch(() => {})` — discarding the error with no log. The customer-key path logs a `console.error`. A persistent write failure on the service key's `lastUsedAt` is silently invisible, making it impossible to detect if audit telemetry is broken for the most-privileged credential class. Mirror the customer-key pattern: `.catch((e) => console.error('Failed to update lastUsedAt for service key', e))`.

info

All service keys minted with wildcard scopes ['*'] — no least-privilege issuance

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

createServiceKeyShell always mints `scopes: ['*']`. The scope gate works correctly (this diff's regression tests prove it), but every service key has platform-wide authority. A compromised `sk_` token grants full access to all scope-checked infra routes. Consider accepting a `scopes` parameter in `CreateServiceKeyCommand` for least-privilege issuance (e.g. `cfe-pipeline` gets `['cfe:collect']`). Not urgent while service keys are Batu-internal, but worth tracking.

tests4

high

rotate contract declares no 400 — ApiKeyServiceRotationUnsupported collapses to 500 on wire

apps/platform/src/api/contracts/api-keys.contract.ts:145

The rotate contract (lines 145–161) declares 201/401/403/404/409/422/500 but NOT 400. `api-key.mapper.ts` maps `ApiKeyServiceRotationUnsupported` → `fail('API_KEY_SERVICE_ROTATION_UNSUPPORTED', …)` which resolves to HTTP 400 via `codes.ts`. Per ts-rest's anti-pattern, any status not declared in `responses` collapses to a generic 500 — so a customer trying to rotate a service key gets an opaque 500 instead of a descriptive 400. The decision test and error-code registration are both present; only the contract declaration is missing. Fix: add `400: JSendFailSchema(['API_KEY_SERVICE_ROTATION_UNSUPPORTED'] as const)` to the rotate contract responses, and add a handler-level test asserting the HTTP 400 path end-to-end.

medium

shells.test.ts buildKey missing `kind` field — service-key guard in decideRotate never fires in shell tests

domains/core/src/api-key/__tests__/api-key.shells.test.ts:81

The `buildKey()` fixture in shells.test.ts does not set `kind`, so the `rotateApiKeyShell` tests exercise rotation of a key whose `kind` is `undefined` (or whatever TS default applies). The `ApiKeyServiceRotationUnsupported` guard in `decideRotate` checks `existing.kind === 'service'` — this branch is never reached from shell-level tests. Add `kind: 'machine'` to the default fixture and add one shell test that passes a `kind: 'service'` key and asserts the shell returns an `ApiKeyServiceRotationUnsupported` error.

low

No test for a sk_ credential whose DB row carries kind='machine' (cross-kind mismatch path)

apps/platform/src/api/handlers/public-v1/__tests__/auth-token.service.test.ts:75

The handler routes by prefix (`sk_` → service path) then reads the row's `kind`. There is no test where `findActiveByPrefix` returns a row with `kind: 'machine'` for an `sk_` token. A data inconsistency (row migrated incorrectly) would silently produce a wrong token class. The `kind` discriminator is the core invariant of this branch — it deserves a mismatch test.

info

Passing scope tests don't assert okHandler was called

apps/platform/src/api/utils/__tests__/public-v1-meta-gate.test.ts:255

The two new passing tests ('holding the required scope' + 'wildcard grant') only assert `result.status === 200`. Other tests in the same file assert `expect(okHandler).toHaveBeenCalledOnce()`. Adding the handler-call assertion makes these tests as rigorous as their siblings and would catch a gate implementation that short-circuits to 200 without calling the handler.

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:15
  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:09current
  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