feat/one-api
needs attentionviewing older commitb1bb9c0 · incrementalpre-PRreviewed 2026-07-27 05:09 UTC1H · 2M · 4L · 2I- 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
- 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+589−20packages/api+475−45apps/platform+365−37.claude/rules+65−4packages/database+53−3.github/workflows+26−2
- 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.
Findings · 9
correctness2
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`.
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
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.
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))`.
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
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.
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.
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.
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
- 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:41
- 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:09current
- 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