feat/one-api
needs attentionviewing older commitb843d8a · incrementalpre-PRreviewed 2026-08-09 04:05 UTC1H · 7M · 9L · 5I- Purpose
- Consolidate Batu's fragmented API surface into one canonical form — one ts-rest contract tree, one auth gate, one validator layer, one telemetry seam — with every hop's ownership mechanically enforced.
- Goal
- Ship a clean public-v1 surface (one-api.md compliance) with FCIS import boundaries gated in CI, entity_relationships dead code dropped, and the EventBridge Connection OAuth endpoint to route Step Functions calls through the internal mount.
- Sub-goals
- SG-1: Drop entity_relationships and Contexts (BAT-301) — tables dropped, 10 routes removed, ~3840 lines deleted
- SG-2: FCIS import boundary gate (check-fcis-boundaries.mjs) added to CI with 16-shell shrink-only ratchet
- SG-3: OAuth token endpoint (BAT-294) — POST /api/v1/auth/oauth/token for EventBridge Connection, service keys only, shared verification core
- SG-4: Tinybird telemetry sink (BAT-297) — third sink via after(), new api_request_events datasource, EXPAND phase
- SG-5: Canonical request-path table written into canonical-form.md — hop ownership codified
- SG-6: RLS fixes — transient sealing-key failure not cached, integration test wiring for previously-dead CI gates
- What
- Branch adds OAuth endpoint + Tinybird sink (SG-3/4), drops Contexts + entity_relationships (SG-1), adds FCIS boundary CI gate (SG-2), fixes sealing-key transient failure caching (SG-6), codifies the canonical request path (SG-5). This incremental window (ee2007aa + 0a5cd3fa) contributes SG-2, SG-3, SG-4, and part of SG-5.
- Why
- The one-api consolidation requires the internal mount to be the sole entry point for Step Functions callers. EventBridge Connections need their own OAuth minting surface (BAT-294), and every layer from import boundaries to telemetry must align with the canonical form.
- Areas
- .claude/rules+588−28.github/workflows+211−8apps/platform/src/__tests__+1298−1559apps/platform/src/api+952−1578apps/platform/src/app/api+277−26apps/platform/src/lib/secrets+210−11domains/core+248−47domains/cross-domain+95−12domains/utility+61−18infra/tinybird+45−0packages/api+9−1104packages/database+338−35+1 more
- Blast
- 182 files, +9250/-7403 (net -2153 — mostly deletion of Contexts/entity_relationships). Touches the full API stack: contracts, handlers, shells, queries, CI gates, domain rules, DB migrations, and infra/tinybird.
Findings · 25
correctness5
Tinybird occurred_at captured post-response, not at request time
apps/platform/src/api/utils/public-v1-telemetry.ts
shipToTinybird schedules the append via after(), which runs after the response is sent. occurred_at is then captured as new Date().toISOString() inside the callback — potentially milliseconds to seconds after the actual request. The durationMs in the same record was captured at request time, so occurred_at and durationMs will disagree. Fix: capture occurred_at before calling shipToTinybird and pass it as a field on the record.
Duplicate client_secret in form body: last value wins silently
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
Object.fromEntries(new URLSearchParams(text)) keeps only the last value for duplicate keys. Per RFC 6749 §3.3, duplicate parameters MUST be treated as malformed. Consider detecting duplicates and returning invalid_request (400). URLSearchParams.getAll(key).length > 1 is the check.
oauth/token fire-and-forget catch silently swallows all errors
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
apiKeyQueries.updateLastUsedAt(...).catch(() => {}) discards all failures including connection errors. The parallel call in auth-token.handler.ts logs the error with the apiKeyPublicId for diagnostic traceability. The oauth/token handler should match that pattern.
DUMMY_HASH module-level Promise is correct
apps/platform/src/api/handlers/public-v1/api-key-verify.ts
hashSecret() returns Promise<string>. Awaiting a settled Promise multiple times in JavaScript correctly returns the same value — no correctness issue. The scrypt work is done once at module init.
Sealing key null+non-transient correctly cached
apps/platform/src/lib/secrets/sealing-key.ts
When readFromSecretsManager returns { key: null, transient: false } and SEALING_KEY env is empty, decodeKey('') returns null (0-byte Buffer fails the 32-byte check). Caching proceeds correctly — null returned on every subsequent call for an unconfigured environment.
security5
No rate limiting on OAuth token endpoint
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
The standalone Next.js route at /api/v1/auth/oauth/token is outside the ts-rest mount and does not inherit its IP-based rate limiting. An unauthenticated attacker can send unlimited POST requests to brute-force service key prefixes. Even with scrypt cost, at scale this is a sustained CPU/DB amplification vector. A middleware-level or edge-level rate limit is needed before this endpoint is reachable in production.
Tinybird metrics_runtime token now grants APPEND to api_request_events
infra/tinybird/datasources/api_request_events.datasource
The existing metrics_runtime token was scoped to read operations on metrics datasources. Wiring the telemetry sink through the same token means a token leak now grants APPEND access to api_request_events — recording route, actor, org_public_id, principal, and outcome for every API call. An attacker could inject fabricated records, corrupting analytics and obscuring real usage anomalies. A dedicated write-only APPEND token should be used.
Timing oracle: bk_ fast-rejection leaks key-type information
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
SERVICE_KEY_SECRET_FORMAT.test(clientSecret) rejects customer keys (bk_) immediately before the DB lookup and scrypt verify that run for sk_ keys, creating a measurable timing difference. The endpoint is service-only and undocumented which reduces risk, but routing bk_ keys through verifyPresentedApiKey (or burning a dummy verify before returning invalid_client for format mismatches) would eliminate the oracle.
DB error messages in api-key-verify.ts may leak schema details in logs
apps/platform/src/api/handlers/public-v1/api-key-verify.ts
console.error logs e.message directly. PostgreSQL error messages can include column names, constraint names, or partial query fragments. Consider filtering to error code only (e.g. pgError.code) rather than the full message string if logs are forwarded to a third-party aggregator.
Fire-and-forget updateLastUsedAt using service-role DB is appropriate
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
Service-role DB is appropriate for a last-used-at update keyed by the verified internal matched.id (not user input). Fire-and-forget is appropriate — failure should not block token issuance.
conventions4
OAuth route telemetry gap undocumented — indistinguishable from accidental miss
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
The route bypasses the ts-rest mount, so recordApiRequest is never invoked. The exemption is intentional but undocumented in the route file itself. Without a comment explaining why it is exempt, the next reviewer cannot distinguish intentional from accidental. Add an inline comment or instrument a manual recordApiRequest call.
H1 grey-area: api-key-verify.ts imports @batu/database (client) in handler layer
apps/platform/src/api/handlers/public-v1/api-key-verify.ts
H1 bans drizzle-orm and @batu/database/schema in handlers. This file imports @batu/database (the client module) — not a literal violation, but the same class of infrastructure coupling. Consider passing db as a parameter or owning it in a query layer.
429 forward-declaration in auth.contract.ts needs declared-status guard verified
apps/platform/src/api/contracts/auth.contract.ts
Declaring 429 before the rate-limiter is wired is acceptable per canonical form. But if the handler never returns 429, declared-status.guard.test.ts may flag it as unreachable drift. Confirm the guard test is updated or suppressed.
D5 rule in check-fcis-boundaries.mjs correctly aligned with canonical form
scripts/check-fcis-boundaries.mjs
The script bans any @batu/database import except @batu/database/rls in shells, correctly implementing the canonical form's fifth sanctioned exception.
tests5
Timing-equalization logic in api-key-verify.ts has no dedicated test
apps/platform/src/api/handlers/public-v1/api-key-verify.ts
The security-critical timing-equalization path (burning a dummy scrypt verify on zero-candidate lookups) is only tested transitively through the OAuth route test, which mocks verifySecret to return a plain boolean. The property that verifySecret is called exactly once on the zero-candidate path is untested. The multi-candidate iteration is also only tested with a single-element mock.
DUMMY_HASH mock hoisting dependency is load-bearing but undocumented
apps/platform/src/app/api/v1/auth/oauth/token/__tests__/route.test.ts
The test mocks hashSecret: async () => 'scrypt:dummy:dummy' inside vi.mock. In api-key-verify.ts, DUMMY_HASH = hashSecret(...) is evaluated at module load. Vitest hoists vi.mock before imports so this works today, but the load-order dependency is unexplained. The sealing-key test explicitly documents its vi.resetModules() rationale; the same discipline is warranted here.
String-search boundary in after()-callback test is anchor-fragile
apps/platform/src/api/utils/__tests__/public-v1-telemetry.test.ts
The test slices source between 'after(async' and '// Not in a request scope'. If a second after(async block is added above the Tinybird one, start points at the wrong block and the assertion would miss any @batu/database import in the actual Tinybird callback. Fragility worth noting — works today given the file is short.
event-publisher.ts singleton has no test and no cache-reset seam
apps/platform/src/api/utils/event-publisher.ts
The module-level publisher singleton lacks a reset seam. Unlike sealing-key.ts which exports __resetSealingKeyCacheForTests(), this module will leak its cached instance across test suites if a future test exercises a handler that calls getImmediatePublisher(). Adding a __resetForTests export proactively matches the sealing-key precedent in this same diff.
OAuth route test does not assert updateLastUsedAt called on success
apps/platform/src/app/api/v1/auth/oauth/token/__tests__/route.test.ts
The success test does not verify that updateLastUsedAt was called with the matched key's internal id. Low-priority gap — the route mirrors auth/token which presumably has this assertion.
improvement6
Dual-casing Code/code in describeSecretsError likely dead code for SDK v3
apps/platform/src/lib/secrets/sealing-key.ts
In AWS SDK v3, error codes surface as err.name not err.Code (SDK v2 PascalCase). The Code branch appears to be dead code and adds noise. If any SDK v2 compatibility path exists it should be documented; otherwise drop the Code arm.
admin-required security annotation buried inside compound comment
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
The admin-required comment is a CI-checked security gate. Mixing it mid-sentence with the fire-and-forget rationale makes it easy to miss by both CI grep and reviewers. It should be on its own line in the conventional '// admin-required: <reason>' form.
readParams silently defaults missing content-type to form-encoded
apps/platform/src/app/api/v1/auth/oauth/token/route.ts
A missing content-type header silently defaults to form-encoded parsing. If the body is actually JSON without a content-type header, readParams returns {} and produces a confusing downstream error. A comment explaining this is the intentional RFC default would reduce future confusion.
Timing-equalization security comment removed from point of decision
apps/platform/src/api/handlers/public-v1/api-key-verify.ts
The detailed timing-equalization rationale moved from the DUMMY_HASH declaration to the module docblock. A one-liner cross-reference at the DUMMY_HASH line ('// timing-equalization — see module docblock') would preserve in-place discoverability of the security intent.
as any cast in event-publisher.ts should use a narrower escape hatch
apps/platform/src/api/utils/event-publisher.ts
Casting db as any to satisfy createImmediatePublisher's loose config type discards all type safety. A cast to the specific expected type (or a TODO linking to a type-fix issue) is preferable to an open-ended as any.
Shared RATCHET_D5 string for 16 allowlist entries is the right call
scripts/check-fcis-boundaries.mjs
Using one canonical string documents the exit path uniformly, prevents per-entry rationale drift, and the shrink-only enforcement is the real guard. No change recommended.
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:05current
- 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:09
- 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