← all branches

feat/one-api

needs attentionviewing older commit
9bd8a0c · fullpre-PRreviewed 2026-08-11 02:14 UTC0H · 5M · 9L
The branch
Purpose
Consolidate the Batu API into a single contract tree with unified auth (machine + service + session), enforce RLS for machine callers, add API telemetry, and close the security gaps identified in rounds 1-6 of review.
Goal
One API surface with visibility as metadata on routes (not separate trees). Single /v1 mount accepting all three credential classes. Machine callers get database-enforced tenant isolation via createMachineRLSDb.
Sub-goals
  • SG-1: Service key entity + api_keys kind discriminator
  • SG-2/3: Service token minting and internal-mount dual-accept
  • SG-4: Public-v1 reads run under RLS (createMachineRLSDb + 0069 migration)
  • SG-5: Retire INTERNAL_API_KEY (blocked on EventBridge Connection OAuth design — BAT-294)
  • SG-14: iss=batu JWT branch in validateAuth
  • W2.5: Record which credential class hits the internal mount
  • BAT-294: Connection OAuth path for EventBridge
  • BAT-301: Delete entity_relationships (zero live users, production evidence)
  • BAT-291: RLS not active on app-facing routes — documented and gated
The changes (whole branch)
What
263 files changed (+222k/−10k lines). Core: api_keys table gains kind+org_scope constraints; contexts+entity_relationships tables dropped. Domain: webhook_endpoints removed (zero users). API: one-api.md RouteMeta on all routes; public-v1 contract + handlers; withInternalAuth dual-accept; rate-limit bypass fix; telemetry at mount; IDOR harness. DB: 0069 (machine org claim), 0073 (drop webhook_endpoints), 0075 (identity gate), 0077 (squat-hole drop), 0078-0081 (anon lockdown, sentinel, UPDATE-side squat, token-exchange throttle). Rules: one-api.md, rls-checklist.md, canonical-form.md updated.
Why
API surface consolidation (one-api.md ratified 2026-07-20). Machine callers needed DB-enforced tenant isolation, not just application-layer org filtering. Service keys needed to be attributable so the static INTERNAL_API_KEY can eventually retire. Entity_relationships had zero live writes since 2026-06-11 (production evidence) and was blocking convergence.
Areas
packages/database+213073351apps/platform+45194125domains/core+13823342packages/api+6811380.claude/rules+59533domains/cross-domain+719299domains/utility+237335packages/mcp-server+1353scripts+6160.github/workflows+1798
Blast
263 files, +222k/−10k across 10 areas. Large but coherent: the database churn (213k adds) is drizzle snapshot regeneration for 16 new migrations. Application logic changes are ~9k lines net. All 3 domains + cross-domain + packages/api touched.
16 migrations: some destructive (DROP TABLE contexts, DROP TABLE entity_relationships, DROP TABLE webhook_endpoints CASCADE) DB-enforced tenant isolation for machine callers — new RLS policies active Dual-accept on withInternalAuth is TRANSITIONAL — SG-5 contracts it orgId from memberships[0] is a known convergence hazard for multi-org users
ci· No open PR — CI status not availablecoderabbit· No .coderabbit.yaml in repo

Findings · 14

correctness1

low

constantTimeEquals short-circuits on length mismatch — leaks INTERNAL_API_KEY byte length

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

Returns false immediately when lengths differ, before calling timingSafeEqual. Leaks only the secret's length — documented as an accepted trade-off for a high-entropy fixed-length key. Low risk given the internal mount is not customer-facing. A constant-time compare over a uniform-length buffer would eliminate the oracle entirely.

security3

medium

Rate limiter is per-Lambda-container in-memory — stated limit is not globally enforced

packages/api/src/middleware/rate-limit.ts:68

The rate-limit store is a module-level Map, scoped to one Lambda container. Vercel may spin up many concurrent instances. A single IP can exceed the stated auth budget (10 scrypt calls/min) by landing requests across different warm containers. This is documented as intentional ('abuse prevention, not exact counting') but the gap is worth tracking. A Redis/Upstash-backed store or Vercel's edge rate limiter would close this.

medium

OPTIONS method bypasses withRateLimit on /v1 mount

apps/platform/src/app/api/v1/[...ts-rest]/route.ts:163

All HTTP methods (GET, POST, PATCH, DELETE, PUT) go through withRateLimit before dispatch. The OPTIONS handler calls baseHandler(request) directly, without withRateLimit. No current OPTIONS routes exist in publicV1Contract, but any future route would be unthrottled. Fix: wrap OPTIONS with withRateLimit.

medium

Multi-org session actors on public-v1 resolve memberships[0] as their org — potentially wrong org for multi-org users

apps/platform/src/api/handlers/public-v1/energy-summary.handler.ts:55

Every public-v1 handler extracts orgId as authReq.auth.memberships[0]?.orgId. For machine tokens this is correct (one org by construction). For session tokens with multiple memberships, memberships[0] depends on Supabase hook injection order — not necessarily the org the user intends. No cross-org leak (RLS + app filter enforce tenancy), but a multi-org user could see the wrong org's data. Tracked as a known convergence hazard; the org-claim GUC migration is in progress.

conventions2

medium

api-key.decisions.ts imports InsertApiKeyValues from queries.ts — type flow inversion

domains/core/src/api-key/api-key.decisions.ts:30

The canonical type flow is: Drizzle schema → domain type → decisions. InsertApiKeyValues is defined in api-key.queries.ts but referenced in CreateApiKeyDecision / CreateServiceKeyDecision return types. This inverts the flow: decisions → queries. The import is type-only (not a D1 infra violation) but creates a conceptual dependency inversion. Fix: move InsertApiKeyValues to api-key.type.ts so the direction is type.ts → decisions.ts → queries.ts.

low

Three contracts missing from barrel export in contracts/index.ts

apps/platform/src/api/contracts/index.ts:82

siteMonitoringStatusContract, siteSavingsContract, and portfolioContract are imported and registered in the combined router but NOT re-exported via export * in the barrel (lines 7-46 export 43 other contracts but omit these three). Handlers import directly from contract files so there is no functional breakage today. Fix: add three export * lines to the barrel.

tests4

medium

No integration test pins 'service actor on non-infra route returns 403' — gate metadata change would silently open access

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

The unit test correctly pins that service actors get the unwrapped service-role db. The gate comment says 'route-metadata gate is the control here'. But there is no integration test asserting that a service-token request on an org-scoped endpoint (e.g. /v1/bills) returns 403. If a route's auth metadata is ever widened to include 'service', the service-role connection would have no org boundary. Add an integration test: mint a real service token, call /v1/bills, assert 403.

low

validateAuth: no test for token with orgId but missing orgPublicId claim

packages/api/src/middleware/__tests__/auth.test.ts

Tests cover machine actor (both claims present) and service actor (neither present). No test for a malformed claim where orgId is set but orgPublicId is absent. AuthContext.memberships[0].orgPublicId would be silent-empty; downstream callers reading it would degrade silently. Medium risk only for operator misconfiguration (tokens are Batu-signed), but worth a test to make the degradation explicit.

low

Rate-limit N+1 throttle firing not tested end-to-end through withRateLimit wrapper

apps/platform/src/api/utils/__tests__/public-v1-rate-limit-bucket.test.ts

Bucket keying and bypass-fix are unit-tested. But no test fires N+1 requests through withRateLimit and asserts a 429. A regression in the wiring (wrong budget object or wrong key passed to checkRateLimit) would not be caught. The individual pieces are each tested; the integration point is not.

low

OAuth route rate-limit bypass fix not cross-checked against main mount's fix

apps/platform/src/app/api/v1/auth/oauth/token/__tests__/route.test.ts

The bypass fix for the standalone OAuth route is independently tested; the main mount's fix is tested via public-v1-rate-limit-bucket.test.ts. But no shared test asserts both routes use the same IP-keying logic. If the standalone route regresses independently, the bucket test for the main mount stays green.

improvement4

low

Dead branch in withInternalAuth headers extraction — both arms read req.headers

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

Line 138: `req instanceof Request ? req.headers : req.headers` — both branches of the ternary return the same expression. Both types have .headers. Simplify to `const headers = req.headers;`.

low

Inconsistent updateLastUsedAt error handling — service path silently drops errors, machine path logs

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

Service-key path (line 106) swallows errors with .catch(() => {}); machine-key path (line 172) logs a structured console.error. Both are intentionally fire-and-forget, but diverging behavior means future hardenings won't apply uniformly. Extract a shared fireAndForgetLastUsed(database, matched) helper.

low

loadPrivateKey / loadPublicKey are near-identical — extract a generic lazyKey helper

packages/api/src/auth/public-jwt.ts:161

Both functions follow an identical pattern: check module-level promise, start async IIFE if null, reset on error and re-throw. Only the promise variable, env var, and import call differ. A single lazyKey<T>(holder, loader) closure would eliminate ~25 lines of duplication and ensure any future hardening (retry cap, cache-miss metric) applies to both keys.

low

JWT config env vars re-read on every signBatuJwt / verifyBatuJwt call — inconsistent with key caching

packages/api/src/auth/public-jwt.ts:216

BATU_PUBLIC_API_ISSUER, BATU_PUBLIC_API_AUDIENCE, BATU_JWT_KID are read from process.env on every call (lines 216-218, 284-285). These are immutable for a container's lifetime — the key promises are already cached at module level. Lazy module-level constants would remove three property lookups per hot-path call and make the caching model consistent.

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