feat(accounts): the subscription row kind in the key store

Merged
CH
5d ago
+15629-86

P04 of the native-harness rollout (task "Bringing Codex or Claude Code Max plans into Luke"). This adds a kind to account_provider_key so a Claude setup-token can be stored, sealed, braked and rewrapped the same way an API key is. It ships no UI, and nothing reads the new kind yet.

What changed

  • Schema: kind text NOT NULL DEFAULT 'api_key' (api_key | claude_setup_token, typed via .$type<>() so P18's chatgpt_oauth needs no migration), plus a unique index on (user_id, provider, kind). The migration is additive, generated with pnpm db:generate and hardened with IF NOT EXISTS.
  • Staged primary key: the PK stays (user_id, provider), so a provider holds one row of either kind for now. A save never replaces a row of the other kind: the upsert updates only WHERE kind = <kind>. In that case the route answers rejected: other_kind_held. A follow-up contract PR (P04b) moves the PK and the upsert target to (user_id, provider, kind) so an API key and a subscription can sit side by side, before P11/P13.
  • Seal: an API key's AAD is unchanged ([user, provider]), so every existing row opens as before. Other kinds bind [user, provider, kind], so a sealed key never opens as a different kind.
  • Store: listProviderKeyRows and removeProviderKey take a kind. saveProviderKey takes a kind and answers whether it kept the row. A setup-token is saved unverified, a new PROVIDER_KEY_STATUS that P15 later moves.
  • Routes: PUT and DELETE /api/account/provider-keys take an optional kind. Leaving it out means an API key, so existing desktops behave as before. A setup-token goes through the same 10-per-hour brake. It is never sent to Anthropic. A value without the sk-ant-oat prefix is refused as invalid, and one saved for OpenAI is refused as wrong provider. The answer's keys still lists API keys only. Subscriptions come back in a new optional subscriptions list (kind, hint, status, never the credential), so an old desktop never reads one as a second Anthropic key.
  • Readers: the agent key lookup (account-keys.ts) and both admin queries filter to api_key, so a setup-token never runs an eve step.
  • Rewrap: it walks every row, and each row carries its own kind into the seal's names.
  • Panel/admin: label cases for the new status and rejection, so the exhaustive switches compile. No new UI.

Tests

  • New packages/database/src/models/__tests__/provider-key-store.test.ts: kinds are read, replaced and removed by kind; a token is kept unverified; a save never replaces the other kind; one account can't read or remove another's rows.
  • provider-keys-procedures.test.ts: a setup-token is saved and answered under subscriptions. It is never sent to a provider, answered back or logged, and not stored in the clear. Removal by kind is limited to the bearer's account. Saving either kind over the other is refused, as are a wrong shape and a wrong provider. Token saves count against the brake.
  • provider-key-rewrap.test.ts: a setup-token row moves onto the new version and opens as its own kind.
  • provider-key-seal.test.ts: an API key's seal and a setup-token's seal never open as each other.
  • account-keys.test.ts: an account that holds only a setup-token runs its steps on Luke's key.
  • The wire JSON-schema goldens were regenerated under LUKE_UPDATE_FIXTURES=1. The only changes are the new optional kind, the new subscriptions list, the unverified status and the other_kind_held rejection.

Checks

In pnpm check:affected, the repository, migration, lint and Effect checks passed. The features test suite passed (829/829), but the run reported 3 vitest worker-startup timeouts, and @luke/service typecheck was OOM-killed (exit 137). Both look like the sandbox running out of resources. Run one at a time afterwards, the typechecks for service, web, panel and database passed, and the touched test files passed (25 files, 148 tests). CI gates the rest.

Preview run: the docs/WORKFLOW.md step 3 Preview run (./scripts/run.sh --preview) was waived by the developer for this rollout, until the cohort.

PRIVACY.md is not changed here because nothing exposes the setup-token to users yet. P13 adds the disclosure along with the Settings UI.

Made with Luke


Devin Review