← all branches

feat/one-api

blockedviewing older commit
de7b337 · incrementalpre-PRreviewed 2026-07-27 06:36 UTC4H · 9M · 14L · 6I
The branch
Purpose
One API initiative (W2): unify service-key credential management so Batu backend services authenticate via the same api_keys table as customer machine tokens, eliminating the separate service_keys table and dual auth paths.
Goal
W2 complete: service keys mint via same /v1/auth/token endpoint, internal mount dual-accepts them, createServiceKeyShell produces platform-scoped (orgId NULL) api_keys rows with kind='service', rotate is blocked with a declared 400.
Sub-goals
  • SG-1a: platform-scoped service actor — JWT, AuthContext, gate
  • SG-1b: ServiceKey entity + one-table kind discriminator
  • SG-2: mint service tokens via /v1/auth/token
  • SG-3: internal mount dual-accepts service tokens + scope guard
  • Round-1: address review-panel findings
  • Round-2: repair regression from round-1 (b1bb9c09)
  • Round-3: declared-status hygiene + comment corrections (this diff)
The changes (whole branch)
What
Round 3 incremental (de7b3370 + 21f7e445): api-patterns.md clarified that TypeScript does NOT catch undeclared HTTP status codes (ts-rest handler is any); domain-patterns.md codified postgres.js transaction catch semantics and named 5 open bug sites; pr-checks.yml widened api-key test run to full directory; route-meta guard test pins service-token isolation on real contract tree; rotate endpoint 400 declared; prefix index comment corrected (not unique); internal-auth.ts requires scopesSatisfied(['*']) for full grant; two new scope-gate tests added.
Why
Each round tightens invariants that the previous review found were only argued, not pinned. Round 3 closes the declared-status gap found at the end of round 2 and corrects two comments that stated the prefix index was unique (it is not — it is a plain lookup index, never a unique constraint).
Areas
.claude/rules+202.github/workflows+61apps/platform/src/api+4222domains/core/src/api-key+1895packages/api/src+278
Blast
13 files, ~284 adds / ~38 dels across the round-3 window. Cumulative branch: 40 files, ~1700 adds / ~100 dels across domains/core, packages/api, apps/platform, .github/workflows.
5 confirmed postgres.js in-callback catch bugs documented but not fixed (column-config 3×, secret-config 2×) Unit tests now gated on DB-change condition in CI INTERNAL_API_KEY static-key path lacks timingSafeEqual during W2 dual-accept
ci· No open PR — CI status not available for pre-PR branchcoderabbit· No .coderabbit.yaml in repo

Findings · 15

correctness3

high

CI widening gates api-key unit tests (decisions, shells, service-lib) behind the DB-change condition

.github/workflows/pr-checks.yml:276

Widening from src/api-key/__tests__/api-key.integration.test.ts to src/api-key means decisions, shells, and service-lib test files — pure unit tests — are now gated on steps.db-changes.outputs.changed == 'true'. They cannot collect in the validate job (setup.ts throws without POSTGRES_URL) and are gated in the DB job. A pure decision-logic bug introduced in a non-schema file would skip all three suites. The widening was correct in intent (the new shells.test.ts needs to run) but the gate applies to unit tests that have no DB dependency.

medium

Route-meta guard second for-loop is dead code — admitted is always empty when the first assertion passes

apps/platform/src/api/contracts/__tests__/route-meta.guard.test.ts:105

expect(admitted).toEqual([]) at line 101 proves no publicV1Contract leaf has 'service' in auth. The subsequent for-loop checking if (meta.auth.includes('service')) → expect visibility 'infra' can never execute — the condition is never true. The comment 'Durable form of the same rule, for when infra routes join this tree' is circular: infra routes cannot join publicV1Contract without the 'public visibility throughout' test also failing first. Remove the loop or move it to a test over the internal contract tree where service-auth infra routes actually live.

medium

domain-patterns.md rule 'returning err() COMMITS' is imprecise — only commits when no query error was recorded on the handle

.claude/rules/domain-patterns.md:98

The full postgres.js mechanic: scope() attaches q.catch(e => uncaughtError ||= e) to every query; after callback resolves it re-throws if uncaughtError is set. So returning err() commits ONLY when no in-flight query error was recorded. If a write query inside the callback raises a DB error, uncaughtError is set, the Result is discarded, and the transaction rejects regardless. The documented safe case ('nothing before this point writes') is correct for createServiceKeyShell specifically, but the general statement 'return err() COMMITS' can mislead authors of more complex shells where a prior write failed.

security4

high

5 confirmed postgres.js in-callback catch sites left unfixed — updateColumnConfigShell can COMMIT without outbox event

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

domain-patterns.md documents the bug but does not fix it: column-configuration.shells.ts ~lines 130/215/367 and secret-configuration.shells.ts ~lines 296/825 all use try/catch inside db.transaction() callback returning err(...). postgres.js records the error on the handle and re-throws after the callback resolves — the Result is discarded and the shell throws a raw PostgresError. updateColumnConfigShell (~215) is highest risk: can COMMIT an entity update while the outbox event is never written, breaking the FCIS delivery guarantee. In secret-configuration, AWS compensation logic must stay inside the callback; only the error mapping moves to .catch().

medium

withInternalAuth static-key comparison uses === not timingSafeEqual — timing oracle during W2 dual-accept window

packages/api/src/middleware/internal-auth.ts:78

The legacy x-api-key path still uses apiKey === expected — a non-constant-time JS equality that can leak the comparison position under JIT optimization. The new bearer-token path uses verifySecret (timingSafeEqual under the hood). During W2 dual-accept, both paths are live simultaneously. While the internal API is not publicly reachable (Vercel functions behind Step Functions ACLs), the transitional window leaves a timing oracle on the most privileged credential class. Fix: crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(expected)) with a length pre-check.

medium

scopesSatisfied(['*'] required) correctly rejects empty and narrowed grants — verified as fail-closed

packages/api/src/middleware/internal-auth.ts:86

INFO level: scopesSatisfied(auth.scopes, ['*']) is correctly fail-closed. empty [] → scopeSatisfied fails at !op guard ('*'.split(':') yields ['*', undefined]). ['bills:read'] → fails same guard. The two new tests (narrowed and empty) correctly pin this. No bypass found.

low

findByOrg unchecked OrgScopedApiKey cast relies on SQL NULL semantics — integration test is the only runtime guard

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

eq(apiKeys.orgId, orgId) compiles to col = value; NULL = value → NULL, so service keys are correctly excluded. Cast to OrgScopedApiKey[] is unchecked at runtime. The integration isolation test (added in this diff) directly verifies the invariant against a real DB — that's the correct guard. Optional hardening: add isNotNull(apiKeys.orgId) as an explicit filter or a runtime assertion rows.every(r => r.orgId !== null) before the cast.

conventions3

medium

route-meta.guard.test.ts logical self-contradiction: 'public visibility throughout' will fail the moment the first infra route is added

apps/platform/src/api/contracts/__tests__/route-meta.guard.test.ts:84

The existing 'public visibility throughout' assertion (line 86: every leaf has visibility === 'public') is mutually exclusive with the new 'infra routes must be infra' comment (line 109). When W4 brings infra routes into the contract tree, the blanket public-only assertion will need to be narrowed (e.g. allow 'infra' too) before that migration can land. Flag this for update before W4.

medium

makeRethrowingDb comment overstates accuracy — throws unconditionally, not only when a query error was recorded

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

The helper runs fn({}) then unconditionally throws raise. In real postgres.js, the throw fires only when a query inside the callback stored an error on uncaughtError. The regression tests happen to be correct (mocked insert returns a valid row, so the shape matches real postgres.js behavior), but the model could mislead a future author who tests a path where the callback itself could raise. Add a comment: 'simulates the driver's post-callback rethrow of a query error, not an arbitrary throw; in this mock the callback runs fully'.

low

Shell comment 'only a throw rolls back' ambiguous — applies inside callback, not at .catch() level

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

A future author might add a throw inside .catch() under the mistaken belief that's needed. Clarify: 'only a throw from inside the callback triggers rollback — the .catch() runs after postgres.js has already rolled back'.

tests3

medium

REGRESSION tests assert error code but not absence of throw — .resolves.toMatchObject() would make the invariant explicit

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

The three REGRESSION tests (lines 554, 567, 582) use const result = await createServiceKeyShell(...); expect(result.ok).toBe(false). An uncaught rejection from the shell IS surfaced as a test failure by vitest, so the tests are protective in practice. But await expect(createServiceKeyShell(...)).resolves.toMatchObject({ ok: false, error: { _tag: '...' } }) would make the intent explicit — 'resolves' asserts it did NOT throw.

low

buildKey fixture missing default kind: 'machine' — all non-service fixtures have kind: undefined at runtime

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

buildKey() doesn't set kind in its defaults. TypeScript compiles because overrides spread provides it for buildServiceKey, but other callers get kind: undefined. A test asserting result.value.apiKey.kind === 'machine' for a customer path would silently fail/pass depending on what the shell sets. Fix: add kind: 'machine' as the default.

low

.strict() applied only to eventData shape — missing top-level outbox schema fields would not be caught

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

ApiKeyCreatedEventSchema.shape.eventData.strict().safeParse(...) guards eventData fields but does not validate eventType, schemaVersion, aggregateId, aggregateType, eventMetadata on the top-level outbox entry. A dropped schemaVersion in the outbox write would be missed.

improvement2

medium

Second for-loop in route-meta guard is structurally dead code — gives false impression of a two-layer guard

apps/platform/src/api/contracts/__tests__/route-meta.guard.test.ts:105

The loop body can never execute when the first assertion has already passed. The comment 'Durable form of the same rule' creates false confidence. The first assertion is the sole load-bearing check. Remove the loop, or add a NOTE comment explaining it is intentionally inactive until infra routes exist in this tree.

low

internal-auth.ts retirement is untracked — mount could coexist with narrowed keys indefinitely

packages/api/src/middleware/internal-auth.ts:33

The comment says the mount is 'scheduled for retirement, not migration' but references no Linear issue or wave. W4 (28-call-site convergence) is the highest-risk milestone. Cross-reference to a Linear issue so the scope check doesn't become permanent scaffolding.

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:36current
  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