← all branches

feat/one-api

needs attentionviewing older commit
f4720a3 · incrementalpre-PRreviewed 2026-07-29 02:54 UTC6H · 8M · 7L · 2I
The branch
Purpose
feat/one-api introduces the public API surface for Batu: service token auth, webhook endpoints, and the security hardening required to ship credentials safely.
Goal
Seal webhook signing secrets at rest so a database dump alone yields no usable credential — the final security prerequisite before the public API ships.
Sub-goals
  • SG-1: Service key entity + service_keys table (merged into api_keys with kind discriminator)
  • SG-2: Mint service tokens from /v1/auth/token
  • SG-3: Internal mount dual-accepts service tokens
  • SG-4: Seal webhook signing secrets at rest (AES-256-GCM) — this commit
The changes (whole branch)
What
Replaces the cleartext webhook_endpoints.secret column with secret_sealed (AES-256-GCM) + secret_prefix (display-only). Adds pure sealed-secret.ts crypto module, sealing-key.ts resolver (env var → Secrets Manager), migrates the webhook dispatcher Lambda to open secrets before signing deliveries, and injects SEALING_KEY into the Lambda via CDK.
Why
Webhook signing secrets are recoverable (HMAC needs the actual bytes, hashing is impossible), so they require encryption at rest. Prior to this commit they were stored in cleartext — a database dump would yield all signing secrets.
Areas
domains/core (webhook-endpoint, sealed-secret)+35030apps/platform (handler, mapper, env, secrets)+14525packages/database (schema, migrations)+8010packages/event-bus (dispatcher)+455infra/cdk (events stack)+121.claude/rules, docs (docs/security)+4186
Blast
25 files changed in this commit (+632/-100 excluding snapshots). Branch total: ~64 files, +2945/-234 (excluding generated snapshots). Core areas: domains/core api-key + webhook-endpoint, apps/platform public-v1 auth + webhooks, CDK events stack, DB schema.
security-hardening db-migration lambda-env-change
CI· No open PR — CI status not available for pre-PR branchCodeRabbit· No .coderabbit.yaml in repo

Findings · 23

correctness4

high

Missing SEALING_KEY causes silent delivery loss — EventBridge never retries

packages/event-bus/src/handlers/webhook-dispatcher.lambda.ts:85

When SEALING_KEY is missing the handler logs one error and returns normally. EventBridge treats a normal return as success (no DLQ, no retryAttempts). Skipped endpoints increment neither delivered nor failed counters, and recordDeliveryOutcome is never called, so failureCount never increments and auto-disable never fires. Every webhook event is silently dropped with no alerting. Fix: throw when sealingKey is null and at least one active endpoint exists, so EventBridge routes to DLQ and retries.

medium

SEALING_KEY_ID not injected by CDK — key rotation silently breaks all deliveries

infra/cdk/src/stacks/events/events.stack.ts:149

The Lambda reads process.env.SEALING_KEY_ID ?? 'k1' but CDK only injects SEALING_KEY. Works today because CURRENT_KEY_ID in sealing-key.ts is also 'k1'. During rotation: new key with keyId 'k2' → sealed secrets embed 'k2' → Lambda builds Map with key 'k1' → open() returns null for every endpoint → complete silent outage. Fix: inject SEALING_KEY_ID from CDK alongside SEALING_KEY.

medium

Migration 0067 NOT NULL columns fail if 0066 didn't run on a non-empty table

packages/database/drizzle/0067_seal_webhook_secret.sql:1

Drizzle applies migrations sequentially, but on a branch environment forked after 0066 was committed, the journal marks 0066 as applied but never executes it. If that branch DB has any webhook_endpoints rows, 0067's ADD COLUMN ... NOT NULL fails. A safer form: add as nullable, delete remaining rows as a safety net, then SET NOT NULL.

low

Negative caching on transient Secrets Manager failure — recovery requires process restart

apps/platform/src/lib/secrets/sealing-key.ts:63

cached = null is permanent for the process lifetime. A transient SM network error at first request permanently disables webhook creation until the process restarts. Acceptable in Vercel serverless (cold start re-reads). Undocumented recovery path.

security7

high

SEALING_KEY stored as plaintext in Lambda env via unsafeUnwrap()

infra/cdk/src/stacks/events/events.stack.ts:158

SecretValue.secretsManager().unsafeUnwrap() emits a CloudFormation dynamic reference resolved at deploy time, injecting the raw 32-byte AES key as a plaintext Lambda env var. Any IAM principal with lambda:GetFunctionConfiguration (typical developer/CI role) can retrieve the key — collapsing Secrets Manager's access boundary onto a much broader permission. Fix: read from Secrets Manager at Lambda cold-start via the SDK instead, so the access boundary stays on secretsmanager:GetSecretValue.

high

Migration 0066 unconditionally deletes all webhook endpoints — no guard if assertion is stale

packages/database/drizzle/0066_purge_cleartext_webhook_secrets.sql:1

DELETE FROM webhook_endpoints with no WHERE clause. The comment asserts production has zero endpoints (verified 2026-07-28), but nothing enforces this in the SQL. If the migration runs on a future state or a branch environment with real rows, it silently destroys all endpoint config and secrets with no recovery path. Safer: add a guard (RAISE EXCEPTION if COUNT(*) > 0) so the migration refuses to run if the assertion is wrong.

medium

No key rotation path — single-entry keyring means rotation causes complete outage

packages/event-bus/src/handlers/webhook-dispatcher.lambda.ts:79

The open() API accepts ReadonlyMap<string, Buffer> specifically to support rotation (both old and new key loaded during re-sealing window). But the Lambda always builds a one-entry Map. There is no re-sealing utility. During a key rotation, all rows sealed under the old keyId return null from open() and every webhook is silently skipped for the entire window between new Lambda deploy and full DB re-sealing.

medium

Plaintext secret in CreateWebhookEndpointResult traverses handler — accidental log risk

domains/core/src/webhook-endpoint/webhook-endpoint.shells.ts:119

The shell returns ok({ endpoint, secret }) where secret is the full plaintext signing key. It travels through the handler and response mapper. Any future response-level audit logging, Sentry breadcrumbs, or structured logging that captures handler return values will inadvertently capture it. Consider wrapping in a non-serializable value object or an explicit strip before the logging boundary.

low

FORMAT_VERSION strict equality is correct but fragile if a v0/legacy mode is ever added

domains/core/src/lib/sealed-secret.ts:92

open() rejects anything that isn't FORMAT_VERSION. Correct now. Design constraint: if a legacy format is ever added, the version check must stay a strict set-membership check — never a range or fallback — or version-confusion attacks become possible. Worth a comment to flag this invariant.

info

Uniform null return from open() is correct — no error oracle

domains/core/src/lib/sealed-secret.ts:71

open() returns null for all failure modes. An attacker submitting arbitrary sealed blobs cannot determine whether failure was keyId miss, AAD mismatch, or ciphertext tampering. Correct design.

info

GCM timing side-channel is not exploitable in this deployment model

domains/core/src/lib/sealed-secret.ts:52

Node's OpenSSL GCM tag comparison may not be constant-time, but sealed secrets are stored in the application's own DB — not attacker-supplied inputs. Exploiting a timing oracle would require prior DB write access, at which point there are more direct attack paths.

conventions3

medium

getBatuEnv() defined three times — acknowledged in code but not consolidated

apps/platform/src/lib/env/batu-env.ts:14

The new batu-env.ts exports getBatuEnv(), but the same function exists inline in credentials.handler.ts:58 and admin-organizations.handler.ts:285. The JSDoc even names them. The canonical-form 'explore before creating' rule requires extending existing flows. Here the canonical copy was created new while two callers were left with private copies. If getBatuEnv() is ever updated (new env name), the two old handlers silently diverge. Both handlers should have been updated in this commit.

low

Decision pre-builds event with aggregateId: '' — shell ignores it, FCIS layering smell

domains/core/src/webhook-endpoint/webhook-endpoint.decisions.ts:142

decideCreate() produces a WebhookEndpointCreatedEvent with aggregateId: '' (shell 'post-fills'). But the shell passes inserted.id directly to outboxQueries — it never uses decision.value.event.aggregateId. The event struct in the Decision is intentionally wrong. Canonical FCIS: decisions describe what to write. Compare decideDelete() which correctly sets aggregateId: existing.id. Clean fix: omit aggregateId from CreateWebhookEndpointDecision event type and have the shell supply it.

low

seal() throws plain Error on wrong-length key — escapes shell's Result boundary

domains/core/src/lib/sealed-secret.ts:50

A wrong-sized key throws new Error() which escapes the shell's Promise<Result<...>> as an unhandled rejection. Domain-patterns.md allows exceptions for programmer errors (this qualifies), but the shell has no try/catch around the synchronous seal() call. A misconfigured key would surface as an unhandled exception rather than a Result.err, which is harder to handle uniformly at the caller.

tests7

high

No tests for sealing-key.ts resolver — env var path, SM fallback, negative caching all uncovered

apps/platform/src/lib/secrets/sealing-key.ts

The module exports __resetSealingKeyCacheForTests() as a test seam, but no test file exists. Missing: (1) SEALING_KEY env var correct → resolves; (2) SEALING_KEY with trailing newline (the .trim() comment calls this out) → resolves; (3) wrong-length key → null; (4) negative result cached so SM is not re-called; (5) cache reset unblocks re-read. A regression in the .trim() path would silently misconfigure every webhook in prod.

high

No unit tests for webhook-dispatcher Lambda — missing-key and tampered-ciphertext paths untested

packages/event-bus/src/handlers/webhook-dispatcher.lambda.ts

No test file for the dispatcher. Two new failure modes are completely untested: (1) SEALING_KEY absent → sealingKey null → all endpoints silently skipped; (2) open() returns null for an endpoint (tampered secretSealed, wrong keyId) → endpoint skipped, counter not incremented. Both are undetectable without tests.

high

No handler unit test for webhooks.handler.ts — sealingKey-null 500 path untested

apps/platform/src/api/handlers/public-v1/webhooks.handler.ts

The IDOR integration test relaxes assertions for webhooksCreate to 'no crash body leak' (it's in INFRA_DEPENDENT). No test asserts the 500 response shape (status:'error', data.error:'internal_error'), nor that a valid key produces a 201. A regression removing the null-key guard would not be caught.

medium

webhookSecretPrefix() is untested

domains/core/src/webhook-endpoint/webhook-endpoint.lib.ts

New function used in the shell to compute the DB-stored display prefix, but has no test in webhook-endpoint.lib.test.ts. Missing: correct character count (whsec_ + 6 = 12), behavior on short secret, alignment with the mapper's secret_preview output.

medium

No shell integration test — seal→open round-trip across the DB layer untested

domains/core/src/webhook-endpoint/webhook-endpoint.shells.ts

No webhook-endpoint.shells.integration.test.ts. The key correctness property — seal() → DB insert → read back via queries → open() returns the original secret — is untested. A mapper regression (dropping secretSealed) would leave the dispatcher silently broken with no test catching it.

medium

IDOR test no longer verifies status-code isolation for webhooksCreate when sealing key is available

apps/platform/src/__tests__/integration/public-v1-idor.test.ts

webhooksCreate is in INFRA_DEPENDENT (relaxed to 'no crash body leak'). There is no separate test that seeds a valid sealing key and verifies IDOR isolation for webhook creates. The security property is only tested when CI happens to have SEALING_KEY configured, which it likely does not.

low

Migration ordering not tested — 0066 must run before 0067

packages/database/drizzle/0067_seal_webhook_secret.sql

0067 adds NOT NULL columns relying on 0066 having deleted all rows first. No migration test or guard. Branch environments with the known Drizzle journal-mismatch pattern could silently fail.

improvement2

low

Key map construction duplicated between sealing-key.ts and webhook-dispatcher

packages/event-bus/src/handlers/webhook-dispatcher.lambda.ts:79

sealing-key.ts builds { key, keyId } (SealingKey) while the Lambda re-implements base64 decode, length check, and Map construction from the same env vars with its own SEALING_KEY_ID env var. Two divergent code paths for the same operation. A shared sealingKeyMap() helper would eliminate drift.

low

open() split-on-colon safety deserves a comment — base64url excluding ':' is the non-obvious invariant

domains/core/src/lib/sealed-secret.ts:87

parts.length !== 5 is safe only because base64url never contains ':'. This non-obvious invariant makes the simple split() correct as a framing mechanism. A comment would prevent a future maintainer from 'simplifying' to a different delimiter without realizing the current approach is already sound.

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