← all branches

feat/one-api

needs attentionviewing older commit
41be4c3 · incrementalpre-PRreviewed 2026-08-03 23:49 UTC0H · 5M · 7L · 3I
The branch
Purpose
Deliver a public API (one-api) with machine-credential isolation, RLS-enforced tenant boundaries, and full request telemetry — enabling the convergence of the internal dashboard onto the same surface.
Goal
Harden the public API's security and observability so both customers and the internal dashboard can trust it: RLS isolation, machine-caller tenant enforcement, sealed webhook secrets, and a durable per-request telemetry baseline.
Sub-goals
  • SG-1: Sealed webhook signing secrets at rest
  • SG-2: Machine caller DB-enforced tenant isolation via batu_org_id JWT claim + createMachineRLSDb
  • SG-3: Public read paths and asset-management reads under RLS
  • SG-4: Durable per-request telemetry (api_request_events table + recordApiRequest)
  • SG-5: In-band telemetry write (moved from after() which silently wrote nothing)
  • SG-6: Unit tests that actually block PRs (split from advisory catch-all)
The changes (whole branch)
What
This commit fixes two defects discovered during testing: (1) the telemetry write was deferred with Next's `after()` which silently wrote nothing because `withRequestScope` closes the DB connection before `after()` fires; (2) the `Promise.race` timeout made things worse by abandoning the await while leaving the query queued. Both are now corrected: write is in-band (awaited, ~1ms INSERT). Also adds a null-principal guard so unauthenticated callers cannot append to the table, and promotes three unit test suites to blocking CI steps.
Why
Observability that does not actually write is worse than none — it creates false confidence. The baseline needs weeks of data before internal traffic swamps it, so correctness is time-sensitive.
Areas
apps/platform/src/api/utils+28040apps/platform/src/__tests__/integration+15030packages/database/src/schema+700.claude/rules+5010.github/workflows+294scripts/db+90
Blast
11 files in this increment (+588/-0 lines in diff); whole branch: 86 files, +73171/-255 across domains/core (29), apps/platform (19), packages/api (8), packages/database (6)
no open PR yet pre-PR branch incremental review (baseline: 32142a87)
CI· no open PR — push-triggered review on pre-PR branchCodeRabbit· no .coderabbit.yaml in repo

Findings · 16

security6

medium

Session actor principal is the internal Supabase auth UUID, not a public id

apps/platform/src/api/utils/public-v1-meta-gate.ts:147

For `actorType === 'user'`, `auth.userId` is the Supabase auth UUID — an internal identifier, not a customer-visible `prf_…` or `usr_…` prefixed public id. The `ApiRequestRecord.principal` JSDoc and the schema comment both say `principal` is 'a PUBLIC id, the same identifier the customer sees'. This is correct for machine actors; for session actors it is wrong. Access review queries built on this table will see internal UUIDs for session traffic that cannot be correlated to customer identities without an additional join through `profiles`. The code comment acknowledges this and defers the fix to W3, but W3 is when session traffic scales onto this surface — the table is live now. Using `auth.profilePublicId` (already on `AuthContext`, populated from the JWT) would fix this without other changes.

medium

insufficient_scope 403 reveals the required scope name to the caller

apps/platform/src/api/utils/public-v1-meta-gate.ts:66

The `insufficientScope` response body includes the required scope names verbatim: `'This API key is missing a required scope (${required.join(', ')}).'` A caller with a valid but under-scoped credential learns the internal scope vocabulary by probing endpoints. Whether scope names are intended to be public is a product decision; they are not currently documented externally. A more conservative response would be `'This API key does not have the required permissions for this route.'` If scope names are meant to be discoverable, this is fine — but the decision should be explicit.

low

RLS policy grants DELETE on an append-only telemetry table

packages/database/src/schema/api-request-events.ts:76

`for: 'all'` grants SELECT, INSERT, UPDATE, and DELETE to `service_role` on a table documented as append-only. No client-role policy exists, so user-facing code can't delete rows — but any service-role shell or script can. This matches the project pattern (`idempotency_keys` etc.) and is low-risk at current scale. A `for: 'insert'` + separate `for: 'select'` policy pair would enforce the append-only invariant structurally.

low

batu_org_id claim collision has no automated guard against accidental hook emission

.claude/rules/rls-checklist.md

The documentation correctly warns that `batu_org_id` must never be emitted by `custom_access_token_hook` (it would become a cross-tenant read path for browser sessions via PostgREST/Realtime). The warning is documentation-only. There is no CI assertion that verifies the hook's claim set does not contain this key. A future developer adding a claim to the hook could inadvertently add `batu_org_id`. A test that parses the hook SQL and asserts `batu_org_id` is absent from the claim set would make this load-bearing.

info

No retention policy enforced — acknowledged, purge SQL ready but unscheduled

packages/database/src/schema/api-request-events.ts

The schema comment explicitly says to add a retention policy before this surface carries real volume. `scripts/db/api-usage.sql` contains a ready purge statement. The gap is that it is a manual script, not a scheduled pg_cron job. Should be converted before W3 multiplies traffic.

info

durationMs is a 32-bit integer — overflows at ~24 days

packages/database/src/schema/api-request-events.ts:63

Postgres `integer` is signed 32-bit (max ~2.1B ms ≈ 24.8 days). Vercel's function timeout is 60s, so no real request can exceed it. A `bigint` column would be safer for a time measurement, but not urgent at current scale.

conventions1

low

Auth stashed on mutable request via Record<string, unknown> cast — untyped coupling

apps/platform/src/api/utils/public-v1-meta-gate.ts:181

`ctx.request.auth = auth` writes through a `Record<string, unknown>` cast. Nothing enforces that `withPublicApiAuth`'s read site uses the same property name, and `GateContext` does not declare `auth`. The rationale (one JWT verification per request) is sound and documented. The risk is in serverless-only safety: in a long-lived process the pattern would allow stale auth to leak across requests. Currently safe (Vercel serverless), but the coupling is invisible to the type checker.

tests5

medium

waitForRow comment is stale — inverts the actual write semantics

apps/platform/src/__tests__/integration/api-telemetry-persistence.test.ts:34

The JSDoc says 'The write is intentionally off the response path, so poll rather than await.' The commit's entire point is the opposite: the write is now explicitly awaited IN the response path (`await write()` in public-v1-telemetry.ts). The poll still works (it finds the row immediately), but the comment inverts the contract — a future engineer reading it will think polling is load-bearing when it is not. Worse, if `write()` ever reverts to fire-and-forget, the poll still succeeds and the test stays green, silently repeating the defect this commit fixed. The correct pattern is `await recordApiRequest(...)` + immediate `db.select()` check, with the 30s timeout and polling loop removed.

medium

Refusal integration test uses an impossible actor/principal combination

apps/platform/src/__tests__/integration/api-telemetry-persistence.test.ts:102

The test calls `recordApiRequest` with `actor: 'anonymous'` AND a non-null `principal`. In real gate operation, `actor: 'anonymous'` only appears when no token was resolved — by definition `principal: null` — which causes `recordApiRequest` to skip the DB write entirely. A non-null principal means auth was validated; the gate then sets actor to `'machine'`/`'service'`/`'session'`, never `'anonymous'`. The scenario this test exercises (`actor='anonymous' + non-null principal + status=401`) cannot be produced by the gate. It does cover the `outcome: 'refused'` derivation, but it is not evidence that real 403s (scope check, wrong credential class) are written to the durable sink. Use `actor: 'machine'` and `status: 403` to model the real scenario.

medium

Throwing authenticated handler: server_error telemetry path is untested

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

The authenticated branch of `withMetaGate` wraps the handler call in try/catch, emits `{status: 500}` to telemetry, then rethrows (source lines ~191–196). No test provides valid credentials, a throwing handler, and asserts that the structured log line appears with `outcome: 'server_error'` before the error propagates. The unauthenticated branch (token-exchange, lines ~118–125) has the same gap. The comments identify a throwing token-exchange as 'the most interesting endpoint on the surface' — a gap a per-handler approach would miss — yet this is the one execution path with no coverage.

low

Gate tests don't assert the org field in the telemetry log line

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

The successful-request telemetry test asserts `route`, `status`, `outcome`, `actor`, `principal`, and `durationMs` but not `org`. The `org` field (`auth?.memberships[0]?.orgPublicId`) is the one the integration test was built to protect against the spread-bug regression. A unit-level assertion `expect(line.org).toBe('org_01TESTORGA')` would catch a regression at unit speed rather than waiting for the integration run.

low

Source-level guard could false-positive on trailing inline comments mentioning after(

apps/platform/src/api/utils/__tests__/public-v1-telemetry.test.ts:161

The comment-stripping regex `.replace(/^\s*\/\/.*$/gm, '')` only removes lines whose first non-space character is `//`. A trailing inline comment — e.g. `doSomething(); // uses after() under the hood` — would not be stripped, and `\bafter\s*\(` would match it, failing CI spuriously. The block-comment stripping is correct. The current file has no such trailing comments (verified), so this is a future brittleness, not a live bug.

improvement4

low

Inner write() closure in persist() is a leftover from the race-based design

apps/platform/src/api/utils/public-v1-telemetry.ts:77

`persist()` defines an inner `async function write()` wrapping all logic, then immediately `await write()`. The inner function existed when the call was `Promise.race([write(), timeout])` — abandoning the outer await without cancelling `write()` required it to be a named function. That race is gone; the wrapper now adds a nesting level with no semantic purpose. The try/catch and body can be inlined directly into `persist()`.

low

emit() structural type should reference AuthContext directly

apps/platform/src/api/utils/public-v1-meta-gate.ts:135

The `emit` helper annotates `auth` with a hand-written structural type `{ actorType: string; userId: string; memberships: { orgPublicId?: string }[] }` instead of `AuthContext`, which is already imported. If `AuthContext` gains a field or one is renamed, the structural type silently drifts. Using `auth?: AuthContext` ties the annotation to the source of truth.

low

captureOne calls async recordApiRequest without await

apps/platform/src/api/utils/__tests__/public-v1-telemetry.test.ts:46

`captureOne` calls `recordApiRequest(record)` without `await`, then immediately checks the spy. This works because `console.log` is called synchronously before the first `await persist(...)`. But it creates a hidden structural dependency: if the log call is ever moved below an await (e.g. to log the outcome after persistence), the spy assertion runs before the log fires and the test passes vacuously. Making `captureOne` async and awaiting `recordApiRequest` would make the intent explicit.

info

db-changes grep pattern is approaching unreadable length

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

The grep `-qE` pattern is now >200 characters. Extracting it into a multi-line shell variable or a `.grep-patterns` file would make additions and code-review diffs readable without changing behavior.

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