feat(accounts): the subscription row kind in the key store
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:
kindtext NOT NULL DEFAULT'api_key'(api_key|claude_setup_token, typed via.$type<>()so P18'schatgpt_oauthneeds no migration), plus a unique index on(user_id, provider, kind). The migration is additive, generated withpnpm db:generateand hardened withIF 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 onlyWHERE kind = <kind>. In that case the route answersrejected: 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:
listProviderKeyRowsandremoveProviderKeytake a kind.saveProviderKeytakes a kind and answers whether it kept the row. A setup-token is savedunverified, a newPROVIDER_KEY_STATUSthat P15 later moves. - Routes:
PUTandDELETE /api/account/provider-keystake an optionalkind. 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 thesk-ant-oatprefix is refused as invalid, and one saved for OpenAI is refused as wrong provider. The answer'skeysstill lists API keys only. Subscriptions come back in a new optionalsubscriptionslist (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 toapi_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 undersubscriptions. 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 optionalkind, the newsubscriptionslist, theunverifiedstatus and theother_kind_heldrejection.
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