Enforce account_api_keys.expires_at in x-api-key auth (chat#1813)
PR 2 (api) of the async chat-generation migration tracked in recoupable/chat#1813. Builds on database#36.
Scope narrowed 2026-06-24: this PR now ships only the expiry-enforcement half of Spike 1. The mintEphemeralAccountKey helper and the insertApiKey expires_at writer change were removed (commit 9302dde) because neither has a caller in this PR — minting is only exercised by the re-point PR (handleChatGenerate), so shipping it here would be untestable dead code. The removed minting code + its exact wiring spec are preserved in the tracking issue (chat#1813, "re-point" item) and land with that PR.
Why
The scheduled chat-generation path has no client Privy session to forward into the sandbox, and we must not put the long-lived service recoup_sk_ key into model-driven bash (exfiltration boundary, lib/agent/tools/AgentContext.ts). The headless path will mint a short-lived, account-scoped key per run; api auth must reject that key once its TTL passes (defense-in-depth if the per-run delete is missed). This PR adds that enforcement.
What (this PR)
lib/keys/isApiKeyExpired— pure TTL check; NULL/unparseableexpires_at= never expires.getApiKeyAccountId— rejects a key past itsexpires_atwith 401. Backward compatible: existing long-lived keys have NULL expiry → unaffected. Readsexpires_atvia the existingselectAccountApiKeysselect("*").types/database.types— carriesaccount_api_keys.expires_at(column from database#36).
Deferred to the re-point PR (removed here)
mintEphemeralAccountKey(+ its test) — mint arecoup_sk_key with a ~15m TTL; returns{ rawKey, keyId }.insertApiKeyexpires_atwriter param — only ever passed by mint.
⚠️ Security review requested
This touches the api-key auth path (getApiKeyAccountId). The change is additive (reject-if-expired; NULL = valid). Please review the enforcement.
Tests
lib/auth + lib/keys enforcement suites green (8 relevant tests pass). next build passes type-checking; no new tsc errors introduced (repo-wide count unchanged at 200 pre-existing test-type errors, none in touched files).
How to test on preview
The expires_at column must be applied to the preview/test DB first (database#36). Then: insert an account_api_keys row with expires_at in the past → call any x-api-key route → expect 401; NULL/future expiry → 200.
Done-when (this PR)
- Auth rejects a key once expired; existing keys unaffected.
- Mint / wire into the headless run → moved to the re-point PR.
Merge order: database#36 (apply column) → this → re-point /api/chat/generate (mint + inject + delete).
🤖 Generated with Claude Code
Summary by cubic
Add ephemeral, account-scoped API keys for headless chat generation (chat#1813). Keys auto-expire (default 15m) and auth now rejects expired keys; existing long-lived keys are unaffected.
-
New Features
mintEphemeralAccountKey: mints a short-livedrecoup_sk_key per account withexpires_at(default 15m); returns{ rawKey, keyId }for sandbox injection and cleanup.isApiKeyExpired+getApiKeyAccountId: enforce TTL; expired keys return 401, NULL/unparseableexpires_atis treated as non-expiring for backward compatibility.insertApiKeyandtypes/database.types: carryaccount_api_keys.expires_at.
-
Migration
- Apply
database#36to addaccount_api_keys.expires_atbefore merging.
- Apply