← all branches

feat/api-keys-v2

needs attention
b5b6c4c · fullpre-PRreviewed 2026-07-09 16:32 UTC5H · 12M · 8L · 5I
The branch
Purpose
Upgrade API key subsystem to support multiple named active credentials per org
Goal
Enable up to 10 active keys per org (one per integration/environment); replace singleton model that forced credential sharing across integrations
Sub-goals
  • SG-1: Multiple active keys per org with advisory-lock cap enforcement
  • SG-2: 24h rotation grace window — old key mints tokens until rotation_expires_at
  • SG-3: Token exchange checks active + in-window rotating keys via findActiveByPrefix
  • SG-4: Hard-delete flow (revoke → delete) retained
  • SG-5: Admin-only management handlers with RLS enforcement
The changes (whole branch)
What
Dropped per-org unique index on active keys; added rotation_expires_at column; introduced per-org advisory lock for cap enforcement; updated token exchange to check grace window; upgraded admin UI for multi-key listing
Why
Single-credential-per-org model forced integrators to share keys across environments and broke when rotating (all integrations hit 401 during migration window)
Areas
domains/core/src/api-key+350207apps/platform/src/api+8033apps/platform/src/app/[locale]/(dashboard)/credentials+5217packages/database+2117packages/api+23
Blast
23 files, +537/-350 across api-key domain, platform handlers, DB schema, UI components
advisory-lock-replaces-unique-index grace-window-lazy-revocation auth-token-handler-zero-tests drizzle-tx-rollback-gap
CI· no PR — pre-PR branchcoderabbit· no .coderabbit.yaml

Findings · 10

correctness2

high

rotateApiKeyShell: null insert returns err() in tx — no rollback, old key stuck in rotating

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

After old key is flipped to rotating, if insert() returns null the shell returns err() without throwing. Drizzle rolls back only on throw. Old key is left permanently in rotating state with no successor — blocks re-rotation until 24h grace window expires. Fix: throw inside the transaction on null insert.

medium

Revoking a rotating key leaves replacement key active — silent orphan

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

decideRevoke allows rotating->revoked. The new active key from the rotation remains alive with no link to the revoked key. Operators cancelling a rotation unknowingly leave the replacement credential live.

security2

medium

Timing oracle: prefix-miss ~1ms vs prefix-hit/wrong-hash ~50ms (scrypt)

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

Attacker can distinguish active prefix existence from timing. Prefix is visible to org admins via list endpoint. Fix: dummy scrypt verify when candidates.length === 0.

medium

Per-instance in-memory rate limiter bypassable via distributed serverless requests

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

Each Vercel cold start gets empty rate window. Distributed requests face no effective ceiling. Fix: Redis/Upstash or edge enforcement.

tests4

high

Zero tests for auth-token handler — grace-window feature has no end-to-end test

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

All handler branches (format-reject, prefix-miss, hash-miss, grace-expired, success) are untested. This is the only production consumer of the findActiveByPrefix grace-window logic.

high

No test: grace-window-expired rotating key returns 401

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

If the now parameter defaults incorrectly, expired rotating keys authenticate indefinitely. No test verifies this path.

high

No test: decideCreate name at exactly 64-char boundary (should succeed)

domains/core/src/api-key/__tests__/api-key.decisions.test.ts

Test covers 65 chars (rejected) but not 64 chars. Classic off-by-one boundary gap.

high

No test: countActiveByOrg excludes rotating keys

domains/core/src/api-key/__tests__/api-key.integration.test.ts

This invariant is the only thing preventing rotating keys from consuming cap slots.

improvement2

medium

Expired rotating rows accumulate in admin UI — no lazy cleanup path

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

findByOrg returns all rows including past-expiry rotating rows. They show indefinitely with expired badge.

medium

No duplicate-name guard within org active keys

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

Two active keys can share the same name. Names identify integrations so duplicates create operational confusion.