Enforce account_api_keys.expires_at in x-api-key auth (chat#1813)

Merged
SW
4mo ago
+101-2

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/unparseable expires_at = never expires.
  • getApiKeyAccountId — rejects a key past its expires_at with 401. Backward compatible: existing long-lived keys have NULL expiry → unaffected. Reads expires_at via the existing selectAccountApiKeys select("*").
  • types/database.types — carries account_api_keys.expires_at (column from database#36).

Deferred to the re-point PR (removed here)

  • mintEphemeralAccountKey (+ its test) — mint a recoup_sk_ key with a ~15m TTL; returns { rawKey, keyId }.
  • insertApiKey expires_at writer 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-lived recoup_sk_ key per account with expires_at (default 15m); returns { rawKey, keyId } for sandbox injection and cleanup.
    • isApiKeyExpired + getApiKeyAccountId: enforce TTL; expired keys return 401, NULL/unparseable expires_at is treated as non-expiring for backward compatibility.
    • insertApiKey and types/database.types: carry account_api_keys.expires_at.
  • Migration

    • Apply database#36 to add account_api_keys.expires_at before merging.