Skip to content

feat: self-serve OpenRouter API key provisioning#71

Open
HazAT wants to merge 38 commits into
mainfrom
openrouter-key-provisioning
Open

feat: self-serve OpenRouter API key provisioning#71
HazAT wants to merge 38 commits into
mainfrom
openrouter-key-provisioning

Conversation

@HazAT

@HazAT HazAT commented Jun 9, 2026

Copy link
Copy Markdown
Member

What

Self-serve OpenRouter API key provisioning + usage sync in Abacus. Engineers create their own OpenRouter keys on a new /api-keys page (secret shown once); usage flows into the dashboard as a third tool next to Claude Code and Cursor.

Key pieces

  • /api-keys page — create/disable keys, spend columns (day/wk/mo), setup snippets for Claude Code & Codex; admins see all keys grouped by user and can delete
  • Usage sync — daily cron (00:30 UTC) pulls per-key activity from the OpenRouter Management API, attributed via key-hash → email mapping (openrouter_keys table); 30-day API window, CLI sync/backfill commands included
  • Workspaces — the management key is account-global; admins toggle which OpenRouter workspaces users may create keys in (stored in openrouter_workspaces, managed on the page). 0 enabled = default workspace, 2+ = selector in the create form. Usage records carry the workspace id in organization_id
  • Dashboard — OpenRouter as third tool across all UI surfaces (additive only)
  • Offboarding — nightly cron disables keys of inactive Google Workspace users
  • Feature flag — page + nav gated behind the api-keys-page Vercel flag (404 when off; on by default in dev)
  • Auth — Google login via better-auth OAuth proxy on previews; *.sentry.dev origins trusted

Env / deploy

Var Notes
OPENROUTER_MANAGEMENT_KEY Required for provisioning + sync (account-global)
ADMIN_EMAILS Comma-separated; admin view, delete, workspace toggles
FLAGS / FLAGS_SECRET Vercel Flags — create the api-keys-page boolean flag in the dashboard

Migrations 00150020 run on deploy. Cost semantics: OpenRouter usage is USD as-is (no /100); BYOK usage excluded.

Tests

364 tests green (route, sync/MSW, cron), tsc --noEmit clean, docs updated (provider, env vars, CLI, deployment).

🤖 Generated with assistance from Claude Opus 4.5

HazAT and others added 13 commits June 9, 2026 10:43
Add OpenRouter key persistence schema and typed API wrapper utilities.

This commit introduces a new openrouter_keys table with index and typed helper aliases.
It adds drizzle migration files for schema creation and column changes.
It installs @openrouter/sdk and adds a function-based wrapper that reads
OPENROUTER_MANAGEMENT_KEY from env to provide create/list/update/delete operations.
Migration generation and TypeScript typecheck were verified after changes.
Implement GET/POST/PATCH/DELETE handlers at /api/openrouter/keys using checkAuth + getSession.
Admin access is derived from ADMIN_EMAILS with query param admin=true.
Writes go to OpenRouter first, then persist hash->email/name mapping in Postgres.
List operations fetch live OpenRouter key data (including disabled state) and project by DB ownership rows.
On create, DB insert failures attempt best-effort OpenRouter key cleanup.
This enables user key self-management and admin hard-delete/list flows.
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Create /api-keys client page for creating and managing OpenRouter keys with admin and user views, including one-time secret reveal modal and setup snippets for Claude Code and Codex.

Add API keys navigation entry and environment placeholders for OpenRouter management/admin configuration in .env.example.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Add route-level Vitest coverage for OpenRouter key management handlers.

The new tests validate auth requirements, validation errors, ownership and admin authorization behavior, and happy-path responses for GET/POST/PATCH/DELETE. They mock '@/lib/openrouter' directly per requested pattern and seed the test DB for auth-bound key mapping.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Align OpenRouter key provisioning route handlers with SDK error semantics, map rate-limit and 5xx errors to explicit HTTP statuses, and normalize response payloads to created_at for UI consumption.

- configure OpenRouter client with timeout and bounded retries
- add robust error mapping for 429 and 5xx failures in key management endpoints
- remove disabled column from persisted openrouter_keys schema and add migration
- ensure create flow cleanup on DB persistence failures
- refresh API route client-side behavior and add full test coverage for mapping/admin/email normalization/cleanup paths with typed SDK fixtures

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
The app accesses Postgres via Neon's serverless driver (@vercel/postgres +
@neondatabase/serverless), which speaks Neon's WebSocket and HTTP "/sql"
protocol rather than a plain TCP connection. That made local development
against docker-compose Postgres impossible without a translating proxy.

Replace the bare wsproxy service with timowilhelm/local-neon-http-proxy,
which serves both the WebSocket and HTTP endpoints on port 4444, and add
src/lib/neon-local.ts to point the driver at that proxy when POSTGRES_URL
targets localhost outside production. It is imported from the data-access
entrypoints (auth, db, queries) and is a no-op everywhere else.

Pin @neondatabase/serverless to 0.9.5 via a pnpm override so the driver
matches the protocol the local proxy implements.
Add nullable revoked_at timestamp to openrouter_keys schema and generate matching Drizzle migration. This enables cron audit tracking for offboarded keys without introducing local key state flags.\n\nGenerated the migration via pnpm drizzle-kit generate per db-migrate workflow; no disabled column added.\n\nCo-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Add a new pure Google Directory API client for checking Workspace account state during offboard flows.

The client reads GOOGLE_SA_KEY_JSON and GOOGLE_DIRECTORY_SUBJECT, creates a JWT client with
admin.directory.user.readonly scope, and calls Directory users endpoint to classify accounts.

It returns inactive for suspended, archived, or missing/deleted users, and unknown for 403,
parse/network/transport errors and any other uncertain condition.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Add GET/POST cron handler for disabling keys tied to inactive Google Directory accounts.

- Mirrors sync-anthropic cron auth/sentry guard pattern.
- Returns skipped response when Google Directory config is missing.
- Reads distinct non-revoked emails, checks status in batches of 5.
- Disables inactive users' non-revoked keys and records revokedAt timestamps.
- Adds robust per-email and per-key error handling with summarized response.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Add comprehensive tests for GET/POST cron revoke-offboarded route auth, config skip, inactive-account paths, error tolerance, and summary output. Add unit tests for google directory configuration and account status handling across active/inactive/error states using mocked Google JWT and fetch.\n\nCo-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add nightly /api/cron/revoke-offboarded Vercel cron entry and document Google Directory
(GOOGLE_SA_KEY_JSON and GOOGLE_DIRECTORY_SUBJECT) in .env.example and provider env docs.

These variables are required for offboarding automation that disables keys for suspended/deleted
Google Workspace users using domain-wide delegation with admin.directory.user.readonly scope.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Remove the unused second parameter from the updateOpenRouterKey mock callback so the targeted ESLint no-unused-vars rule no longer warns. The mock only branches on hash; the params argument was never read. No behavioral change to the test.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The app accesses Postgres through Neon's serverless driver (@vercel/postgres /
@neondatabase/serverless), which speaks Neon's WebSocket + HTTP protocol rather
than plain TCP. To run against a docker-compose Postgres, a763192 added a
local-neon-http-proxy container, a src/lib/neon-local.ts shim that repointed the
driver at localhost:4444, side-effect imports of that shim in auth/db/queries,
and a pnpm override pinning @neondatabase/serverless to 0.9.5 to match the
proxy's protocol.

We now develop against a real Neon branch of the project instead, so the driver
talks to a genuine Neon endpoint and the translation layer is unnecessary.
Preview deployments get their own Neon branch automatically via the Neon-Vercel
integration.

Remove the proxy and shim entirely: delete docker-compose.yml and
src/lib/neon-local.ts, drop the three './neon-local' side-effect imports, and
remove the pnpm override so @neondatabase/serverless resolves to ^1.0.2 (1.1.0)
again. Update local-development docs and .env.example to describe the Neon
branch workflow. Verified both DB paths (@vercel/postgres sql and the
@neondatabase/serverless Pool used by better-auth) connect to a branch; tsc,
314 tests, and the production build all pass.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
abacus Ready Ready Preview Jun 12, 2026 1:32pm

Request Review

return mapOpenRouterError(error);
}

return NextResponse.json(normalizeListItem(updated.data));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offboarded keys can be re-enabled

High Severity

The nightly offboarding job disables OpenRouter keys and sets revoked_at on the mapping row, but PATCH still calls OpenRouter to set disabled: false for any owner (or admin) without checking revoked_at. A user with a valid Abacus session can undo offboarding and restore API access after keys were revoked for a suspended or deleted Workspace account.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3d0ba0. Configure here.

Comment thread src/app/api-keys/page.tsx
Comment thread src/app/api/openrouter/keys/route.ts
Comment thread src/app/api/openrouter/keys/route.ts Outdated
Comment thread .env.example
Run deploy-time migrations over POSTGRES_URL_NON_POOLING when available,
falling back to POSTGRES_URL. Neon recommends running DDL outside PgBouncer,
and the Neon Vercel integration injects the non-pooling URL on every
deployment; local dev keeps working via the fallback.

Update the db-migrate skill to state up front that applying migrations is
automatic (the Vercel build runs db:migrate before next build) and that the
skill's job is generating migrations correctly. Rewrite the local-testing step
for the Neon-branch workflow that replaced docker-compose: check .env.local
points at a personal dev branch and use pnpm db:migrate instead of pnpm build.

Verified: tsc clean; cmdDbMigrate runs idempotently against the dev/haza
branch via both the non-pooling and fallback env vars.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Comment thread src/app/api/openrouter/keys/route.ts Outdated
Comment thread src/app/api/openrouter/keys/route.ts Outdated
HazAT and others added 7 commits June 10, 2026 12:31
Add getOpenRouterActivity() fetch wrapper in src/lib/openrouter.ts alongside
the existing key-management helpers, and implement the full provider contract
in src/lib/sync/openrouter.ts:

- syncOpenRouterUsage(startDate, endDate): loads all openrouter_keys rows
  (including revoked — pre-revocation usage lives in the 30-day window),
  calls getUserActivity({ apiKeyHash }) sequentially per key, filters items
  to the requested range in code, aggregates in memory per
  (email, date, rawModel, endpointId) to prevent dedup-tuple collisions for
  users with multiple keys, then upserts via insertUsageRecord().
- syncOpenRouterCron(): today + yesterday UTC with early-exit guard if already
  synced today (mirrors syncAnthropicCron concurrency caveat).
- backfillOpenRouterUsage(): full 30-day window sync, then marks
  backfillComplete = true (API cap; no deeper history available).
- State helpers: getOpenRouterSyncState(), getOpenRouterBackfillState(),
  resetOpenRouterBackfillComplete() — same shape as cursor.ts/anthropic.ts.

Key data decisions:
- cost = usage (USD dollars from OpenRouter credits); do NOT divide by 100.
- byokUsageInference intentionally excluded: we don't provision BYOK keys and
  mixing billing sources would corrupt cost reporting.
- outputTokens = completionTokens + reasoningTokens (reasoning billed same).
- model: vendor prefix stripped from slug, then normalizeModelName() applied.
- rawModel: full untouched slug preserved for future re-normalization.
- toolRecordId = endpointId for stable dedup key.

MSW-based unit tests cover: token/cost mapping, reasoning-token math,
same-email multi-key aggregation, revoked key inclusion, date-range filtering,
per-key error isolation, idempotent re-sync, and model normalization pinning
for Claude, GPT, and Gemini slugs (21 tests, all passing).

Real-API smoke verification confirmed: management key returns { data: [...] }
with snake_case fields; per-key apiKeyHash filter confirmed as a query param.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add OpenRouter to the full sync pipeline and expose it via all
relevant CLI commands.

src/lib/sync/index.ts:
- Import all five public functions from the openrouter module
  (syncOpenRouterUsage, syncOpenRouterCron, backfillOpenRouterUsage,
  getOpenRouterSyncState, resetOpenRouterBackfillComplete)
- Add openrouter?: OpenRouterResult to FullSyncResult
- Add runOpenRouterSync() wrapping syncOpenRouterCron() — the cron
  route (todo #463) will call this
- Include syncOpenRouterUsage(start, end) in the runFullSync()
  Promise.all; a missing key returns an error SyncResult so the
  whole sync never throws
- Re-export the five functions alongside the Anthropic/Cursor exports

scripts/cli/sync.ts:
- Extend SyncOptions.tools to include 'openrouter'
- Add 'openrouter' to the default tools list so bare `sync` covers
  all three providers
- Skip with a warning if OPENROUTER_MANAGEMENT_KEY is absent
- Print sync results for the openrouter case in cmdSync()
- Refactor cmdBackfill() to accept 'openrouter': takes no --from
  (always fills the full 30-day window), rejects --from with a clear
  message; anthropic/cursor paths are unchanged
- Extend cmdBackfillComplete/cmdBackfillReset to include 'openrouter'

scripts/cli/index.ts:
- Import cmdOpenRouterStatus from the new scripts/cli/openrouter.ts
- Wire 'openrouter:status' case (mirrors anthropic:status/cursor:status)
- Update 'sync' case to parse 'openrouter' as a tool arg
- Update 'backfill' case to accept openrouter (no --from required)
- Extend backfill:complete and backfill:reset type guards
- Update help text throughout

scripts/cli/openrouter.ts (new):
- cmdOpenRouterStatus(): prints last synced date, hours behind, and
  backfill completeness — mirrors cmdAnthropicStatus / cmdCursorStatus

Verified locally:
  pnpm cli sync openrouter --days 7   → completes (0 keys provisioned)
  pnpm cli backfill openrouter        → backfill_complete=true in sync_state
  pnpm cli openrouter:status          → prints state correctly
  pnpm cli sync --days 2              → includes OpenRouter in full sync
  pnpm tsc --noEmit                   → clean
  pnpm test                           → 335/335 pass

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Add the production cron surface for OpenRouter usage syncing:

- src/app/api/cron/sync-openrouter/route.ts: new cron handler
  mirroring sync-cursor shape — CRON_SECRET Bearer check, calls
  runOpenRouterSync() from @/lib/sync, skips gracefully when
  OPENROUTER_MANAGEMENT_KEY is absent, returns JSON status. No
  backfill cron added (rolling 30-day window makes it redundant).

- vercel.json: add hourly entry for /api/cron/sync-openrouter
  (0 * * * *), matching the Cursor cadence.

- docs/providers/openrouter.mdx: new provider page documenting
  what's synced (daily activity per provisioned key), email
  attribution via hash mapping, 30-day history limit, absence of
  cache-token data, BYOK exclusion, and USD credit cost semantics.

- docs/getting-started/environment-variables.mdx: add
  OPENROUTER_MANAGEMENT_KEY under Provider Variables.

- docs/cli/usage-data.mdx: extend sync, backfill, and status
  examples with openrouter commands; note that backfill openrouter
  takes no date flags (always full 30-day window).

- docs/deployment/vercel.mdx: add sync-openrouter to the cron
  schedule table; add a post-merge note about setting
  OPENROUTER_MANAGEMENT_KEY in Vercel env.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Register openrouter in TOOL_CONFIGS (violet color family) so all dynamic
tool components (ToolDistribution, ToolSplitBar, Legend) pick it up.

Extend queries.ts with openrouterTokens/openrouterUsers CASE columns in
every hardcoded two-tool site (getOverallStats, getOverallStatsWithComparison,
getUserSummaries, getUserDetails, getUserDetailsExtended, getAllUsersPivot,
getDailyUsage) and add openrouter to DataCompleteness/DailyUsage types.

Update projection.ts to include openrouter in the TOOLS loop; guard optional
completeness entry so existing tests without an openrouter key still pass.

Extend UI consumers additively:
- page.tsx: Stats/DailyUsage interfaces + ToolDistribution tools spread
- usage/page.tsx: Stats interface, tool breakdown table rows, tool adoption
  legend, chart bars and tooltip, user chart legend/table rows
- team/page.tsx: UserPivotData, columns, totals reducer, cell renderer
- users/[email]/page.tsx: UserDetails.summary + dailyUsage types
- UserTable.tsx, UserDetailPanel.tsx: UserSummary + getToolBreakdown
- UsageChart.tsx: openrouter bar series + legend + tooltip
- api/export/team: openrouter_tokens CSV column

All existing claude_code/cursor query expressions unchanged. Tests: 335
passed. pnpm tsc --noEmit: no errors.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Adds a CLI command to create an OpenRouter API key for a given user
email without going through the web UI:

  pnpm cli openrouter:create-key <email> <name>

It mirrors the POST /api/openrouter/keys flow exactly: the key is
created on OpenRouter first (named '<email> - <name>'), the hash ->
email mapping is stored in openrouter_keys, and the OpenRouter key is
deleted again if the DB insert fails. The raw key is printed once for
secure sharing and never persisted.

This lets admins provision keys for employees directly from a machine
with OPENROUTER_MANAGEMENT_KEY in .env.local, with the mapping wired up
so usage sync attributes their traffic correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Address all NEEDS CHANGES items from code review scratchpad #446:

P1 — Test timeout: the 500-error retry test now carries a 20 000 ms
per-test timeout so it passes under the default Vitest runner without
raising the global threshold. The SDK's max-elapsed-time backoff (4 000
ms) took longer than Vitest's default 5 000 ms limit.

P2 — Model normalization: normalizeModelName() was missing the case
where OpenRouter returns claude-{family}-{decimal} slugs such as
'claude-sonnet-4.5'. A new pattern (^claude-([a-z]+)-(\d+\.\d+)$)
normalizes these to the canonical 'sonnet-4.5' / 'opus-4.5' form so
OpenRouter Claude usage aggregates with existing Anthropic rows instead
of fragmenting. Test expectations in openrouter.test.ts updated
accordingly.

P2 — 404 skip: a deleted key returning NotFoundResponseError is now
caught before the generic error handler and treated as no-activity
(console.warn + continue). It no longer sets result.success = false or
blocks sync_state updates. A new test verifies: one 404 key + one active
key → result.success true, no errors, one record imported, sync state
updated.

P2 — UI completeness: four hardcoded two-tool paths now include
OpenRouter (violet, consistent with TOOL_CONFIGS):
• team/page.tsx: getToolBreakdownFromUser + columns list
• usage/page.tsx: Active Users by Tool bar chart + tooltip
• users/[email]/page.tsx: weekly pattern day aggregation
• RawUsageTable.tsx: local TOOL_COLORS + formatToolName map

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Live verification against the OpenRouter API showed that the /activity
endpoint only returns completed UTC days: today's usage is visible on
the OpenRouter dashboard immediately but does not appear in the
activity API until the UTC day rolls over. Polling hourly therefore
yields no new data 23 times a day.

Changes:
- vercel.json: schedule '0 * * * *' -> '30 0 * * *' (shortly after UTC
  midnight, when the just-completed day becomes available)
- syncOpenRouterCron(): target the last two completed UTC days
  (yesterday + day before) instead of today + yesterday, and early-exit
  when yesterday is already synced; sync_state now tracks the latest
  completed day rather than today
- tests updated to pin the completed-day semantics
- docs: document the completed-UTC-day freshness behavior in the
  provider page and the Vercel cron table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread src/lib/sync/openrouter.ts
Comment thread src/app/api/openrouter/keys/route.ts
HazAT and others added 8 commits June 11, 2026 11:27
Add src/lib/openrouter-workspaces.ts — a pure env-parsing module (zero
internal imports) that parses OPENROUTER_MANAGEMENT_KEYS (JSON object,
name → management key) with OPENROUTER_MANAGEMENT_KEY single-key fallback.

- getOpenRouterWorkspaces() returns [] when neither var is set; throws on
  malformed JSON, non-object value, empty names, or empty key values.
- getOpenRouterWorkspaceKey(name) throws with configured-name list on miss.
- getOpenRouterWorkspaceNames() convenience accessor.
- NO_OPENROUTER_WORKSPACES_ERROR constant exported for callers.

Refactor src/lib/openrouter.ts: all five SDK wrappers (createOpenRouterKey,
listOpenRouterKeys, updateOpenRouterKey, deleteOpenRouterKey,
getOpenRouterActivity) now take a required workspace: string as their first
argument. getClient(workspace) resolves the API key via the registry.
Drop unused workspaceId fields from CreateOpenRouterKeyParams and
ListOpenRouterKeysOptions — the management key itself selects the workspace.

Update all call sites mechanically so the branch compiles green and all tests
pass. Where a call site has no workspace column yet (todos 3–5 replace these),
resolve via workspaces[0]?.name ?? 'default':
- src/lib/sync/openrouter.ts: env guards → registry check; single-workspace
  resolution in the key loop; NO_OPENROUTER_KEY_ERROR re-exported from the
  new constant so existing tests keep passing.
- src/app/api/openrouter/keys/route.ts: mechanical workspace resolution +
  mapOpenRouterError updated to match NO_OPENROUTER_WORKSPACES_ERROR.
- src/app/api/cron/revoke-offboarded/route.ts: mechanical workspace resolution.
- scripts/cli/openrouter.ts: mechanical workspace resolution.

Add src/lib/openrouter-workspaces.test.ts (19 new tests) — JSON map parsing,
order preservation, plural-wins, singular fallback, empty/neither, malformed
JSON, non-object, empty name, empty value, unknown workspace lookup with list.

Update route.test.ts and revoke-offboarded/route.test.ts to match new
workspace-prefixed call signatures.

pnpm tsc --noEmit clean; pnpm test: 355 passed (39 files).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…organization_id

Adds a NOT NULL `workspace` varchar(255) column to `openrouter_keys` and
backfills all existing rows to 'Coding Agents'. The migration uses the safe
nullable → UPDATE → SET NOT NULL pattern to avoid failures on existing data.

Also backfills `usage_records.organization_id = 'Coding Agents'` for all
openrouter rows where organization_id was NULL. This is critical for dedup
correctness: the unique index on usage_records includes
COALESCE(organization_id, ''), so once the sync starts stamping workspace
names in todo 3, re-synced days would produce duplicate rows for records
that currently have NULL organization_id.

The workspace column has no schema-level default — callers must always
supply it explicitly at insert time (enforced by TypeScript via
`NewOpenRouterKey`). Updated all insert sites (route.ts, cli/openrouter.ts)
and test fixtures accordingly.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Replace the mechanical single-workspace resolution (workspaces[0]) from
todo 1/7 with real per-row routing in two call sites:

syncOpenRouterUsage:
- Selects `workspace` alongside `hash`/`email` from openrouter_keys.
- Calls getOpenRouterActivity(key.workspace, ...) for each row, so each key
  uses the management key of its own workspace.
- Aggregation key now includes workspace: 'email|workspace|date|rawModel|endpointId'.
  A user with keys in two workspaces produces two separate rows — never merged.
- Stamps insertUsageRecord({ organizationId: rec.workspace }) so every usage
  row carries the workspace name (ISC decision 2a).

revoke-offboarded cron:
- Selects `workspace` alongside `hash` in handleEmail's key query.
- Calls updateOpenRouterKey(row.workspace, hash, { disabled: true }) so the
  disable request is routed through the correct workspace management key.
- Removes the now-unused getOpenRouterWorkspaces import from the route.

Tests added:
- syncOpenRouterUsage: two workspaces, correct Authorization header per call,
  organizationId stamped on each record (ISC-6).
- syncOpenRouterUsage: same email, two workspaces → two separate usage rows (ISC-7).
- syncOpenRouterUsage: unconfigured workspace row → per-key error, other keys
  still synced (ISC-9 + ISC-11).
- revoke-offboarded: key rows with different workspaces each call updateOpenRouterKey
  with their own workspace name.
- Added vi.unstubAllEnvs() to outer beforeEach to prevent env-var bleed between
  multi-workspace tests and the rest of the suite.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Wire workspace awareness through all four /api/openrouter/keys handlers and
add GET /api/openrouter/workspaces for frontend workspace selection.

POST: accepts optional `workspace` body field; when 2+ workspaces are
configured, workspace is required and must match a configured name (400
listing valid names); with exactly one workspace configured it defaults
automatically.  Response now includes `workspace`.

GET: iterates all configured workspaces, calls listOpenRouterKeys per
workspace, tags each normalised item with `workspace: ws.name`.  A
single workspace failure no longer aborts others — partial results are
returned; only when every workspace fails is the mapped error returned.

PATCH: now derives the management key from `row.workspace` (the DB row
fetched for owner/admin validation) instead of taking workspaces[0].

DELETE: previously never fetched the DB row; now looks up the row by
hash first (404 if absent) and uses `row.workspace` for the
deleteOpenRouterKey call.

New route src/app/api/openrouter/workspaces/route.ts: session-validated
GET that returns `{ workspaces: string[] }` in env declaration order
using getOpenRouterWorkspaceNames().

Tests expanded from 21 → 32 covering: POST multi-workspace routing,
workspace validation errors, GET multi-workspace merge + tagging,
partial failure, PATCH/DELETE row.workspace assertions, DELETE 404,
workspaces endpoint auth + env variants.

pnpm tsc --noEmit: clean
pnpm test:       370 passed

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
cmdOpenRouterCreateKey now accepts an optional workspaceFlag parameter:
- With exactly 1 workspace configured: flag is optional, defaults to that workspace.
- With 2+ workspaces: --workspace is required; omitting it prints valid names and exits 1.
- Invalid workspace name: prints valid names and exits 1.
- Zero workspaces configured: error and exits 1.

Success output now includes the chosen workspace name for confirmation.
CLI help text updated to reflect the new [--workspace <workspace>] argument.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…in tables

When 2+ workspaces are configured (via OPENROUTER_MANAGEMENT_KEYS), the
/api-keys page now:
- Fetches workspace names on mount from GET /api/openrouter/workspaces
- Shows a <select> dropdown in the create-key form (between name input and
  submit button), defaulting to the first (primary) workspace
- Includes the selected workspace in the POST body so the backend routes
  the new key to the correct management key
- Adds a Workspace column to both the user (My Keys) and admin (AllKeysList)
  key tables, displaying each key's workspace from the field now present on
  GET /api/openrouter/keys items
When only 1 workspace is configured, none of these UI elements render and
the POST body is unchanged — the page is identical to the current state.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
…urfaces

- openrouter.mdx: add Multi-Workspace Configuration section covering
  OPENROUTER_MANAGEMENT_KEYS JSON map format, single-key fallback
  (OPENROUTER_MANAGEMENT_KEY → workspace 'default'), UI visibility
  rules, and a migration caution for existing deployments (migration
  0019 backfilled rows to 'Coding Agents' — switch env var to match)
- environment-variables.mdx: add OPENROUTER_MANAGEMENT_KEYS row to
  the OpenRouter provider table with precedence note and example
- vercel.mdx: update OpenRouter activation note to cover both env
  vars; add Vercel UI vs .env quoting distinction for JSON values
- cli/identity-mappings.mdx: add OpenRouter Key Provisioning section
  documenting openrouter:create-key with --workspace flag (optional
  w/ 1 workspace, required w/ 2+)
- .env.example: add OPENROUTER_MANAGEMENT_KEYS commented example
  alongside existing OPENROUTER_MANAGEMENT_KEY, including shell-
  quoting and Vercel UI guidance

Docs build verified: 18 pages, 0 errors.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
…-listing reporting

Three P1/P2 fixes from code review of the multi-workspace feature:

1. [P1] Cron + CLI sync gated on legacy singular env var only
   - route.ts and scripts/cli/sync.ts checked process.env.OPENROUTER_MANAGEMENT_KEY
     directly, silently skipping sync when only OPENROUTER_MANAGEMENT_KEYS was set.
   - Replace direct env checks with getOpenRouterWorkspaces().length > 0 so
     plural-only deployments work correctly.
   - Add route.test.ts covering plural-only, singular-only, and neither-set cases.

2. [P1] Malformed JSON parse error echoed the raw env value (secret leak)
   - parseWorkspaces() threw with: 'OPENROUTER_MANAGEMENT_KEYS is not valid JSON: ${raw}'
     which logged full management keys (sk-or-...) into error traces and Sentry.
   - Sanitise to a description-only message with no key material.
   - Regression test verifies sk-or- tokens do not appear in the error message.

3. [P2] GET /api/openrouter/keys hid per-workspace failures on partial success
   - When some workspaces succeeded and others failed, the 200 response included
     only silent key omissions (lastError was lost).
   - Add workspaceErrors: [{workspace, message}] to the non-admin 200 response
     when there are partial failures. All-failure path still returns an error status.
   - Frontend: parse the new {keys, workspaceErrors} shape and render a warning
     banner listing failed workspaces.
   - Test updated to assert workspaceErrors field is present and names the workspace.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Comment thread src/lib/sync/openrouter.ts
Comment thread src/lib/openrouter-workspaces.ts Outdated
…obal management key

Rework the multi-workspace design after verifying live that OpenRouter
management keys are account-global (one key administers ALL workspaces),
invalidating the previous one-management-key-per-workspace architecture.

Verified live against the OpenRouter API:
- workspaces.list() returns every workspace with one management key
- apiKeys.create({ workspaceId }) creates the key in that workspace
- apiKeys.list() without a filter returns ONLY default-workspace keys;
  a workspaceId filter is required per non-default workspace
- update/delete/activity by key hash work account-wide with no
  workspace context

Changes:
- Back to a single OPENROUTER_MANAGEMENT_KEY env var; the JSON-map
  OPENROUTER_MANAGEMENT_KEYS registry (openrouter-workspaces.ts) is
  removed along with its tests
- New openrouter_workspaces table: admins curate which live OpenRouter
  workspaces users may create keys in (id + cached display name);
  managed from a new Workspaces section on /api-keys with toggle
  buttons, backed by GET/PUT /api/openrouter/workspaces
- openrouter_keys.workspace (name) replaced by workspace_id (UUID,
  NULL = account default workspace); migration 0020 also drops the
  0019 backfill value which was factually wrong (existing keys live in
  the Default workspace, not 'Coding Agents')
- POST /api/openrouter/keys takes workspaceId validated against the
  admin-enabled set: none enabled = default workspace, one enabled =
  auto-selected, multiple = selector required
- GET lists default workspace plus every workspace referenced by key
  rows (workspaceId filter per call) and tags items with the cached
  workspace name; partial failures still reported via workspaceErrors
- PATCH/DELETE/revoke-offboarded drop workspace routing entirely and
  operate by hash (account-global, verified live)
- Sync stamps usage_records.organization_id with the workspace UUID
  (NULL for default); aggregation key isolates by workspace id
- CLI --workspace matches admin-enabled workspace names
- Docs rewritten: no env JSON map, no migration caution; workspaces
  section documents the admin toggle flow

E2E verified against the live OpenRouter account: create in Tools
workspace, list with filter, disable/delete by hash, activity by hash.
364 tests pass, tsc clean, docs build green.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment on lines +102 to +107
await db.delete(openrouterWorkspaces);
if (ids.length > 0) {
await db.insert(openrouterWorkspaces).values(
ids.map((id) => ({ id, name: liveById.get(id)!.name }))
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The workspace update operation is not atomic. A failure between the delete and insert calls can leave the openrouterWorkspaces table empty, losing the configuration.
Severity: MEDIUM

Suggested Fix

Wrap the db.delete(openrouterWorkspaces) and subsequent db.insert(openrouterWorkspaces) operations within a single db.transaction() block to ensure the update is atomic. This will guarantee that either both operations succeed or the entire change is rolled back on failure.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/app/api/openrouter/workspaces/route.ts#L102-L107

Potential issue: The endpoint for updating workspace settings first deletes all existing
records and then inserts the new ones. This sequence is not wrapped in a database
transaction. If a server crash, network failure, or other error occurs after the
`db.delete(openrouterWorkspaces)` call succeeds but before the subsequent `db.insert()`
completes, the `openrouterWorkspaces` table will be left empty. This results in the loss
of the admin's workspace configuration, causing new API key creation requests to fall
back to the account-level default workspace instead of the intended ones.

Comment thread drizzle/0019_conscious_susan_delgado.sql
Preview deployments are served on the *.sentry.dev custom domain
(e.g. abacus-git-<branch>.sentry.dev) in addition to *.vercel.app, but
trustedOrigins only listed the vercel.app wildcard. The oAuthProxy
callback lands on production (Google only registers the prod redirect
URI), and production rejects redirect-back targets that are not in
trustedOrigins — so logins started from a sentry.dev preview URL fell
back to the production base URL instead of returning to the preview.

Add https://*.sentry.dev to trustedOrigins. Note: this guards the
redirect on the production deployment, so sentry.dev preview logins
only work once this change is deployed to prod; the *.vercel.app
preview URLs are unaffected and keep working meanwhile.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…flag

Hide the OpenRouter API Keys page until it is ready to launch, using
the Flags SDK with the Vercel Flags adapter.

- src/flags.ts defines the api-keys-page boolean flag (vercelAdapter,
  reads the FLAGS env var). defaultValue makes the page visible in
  development and hidden in production/preview when the flag service
  is not configured or unreachable.
- src/app/api-keys/layout.tsx evaluates the flag server-side and 404s
  the page when off — direct URL access is blocked, not just the nav.
- Root layout evaluates the flag once per request and passes it to the
  client through a FeatureFlags context in Providers; MainNav appends
  the API Keys item only when enabled (MobileNav never listed it).
- /.well-known/vercel/flags discovery endpoint added for the Flags
  Explorer in the Vercel Toolbar, allowlisted in proxy.ts since it
  carries its own FLAGS_SECRET-based auth.
- Docs + .env.example describe the FLAGS / FLAGS_SECRET variables.

Verified locally: dev server serves /api-keys with nav entry (flag
defaults on in development); production build returns the auth
redirect for the route and renders no nav entry (flag off without
FLAGS configured). 364 tests pass, tsc clean, docs build green.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
});
}

return NextResponse.json(grouped);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin list hides workspace failures

Medium Severity

For non-admin GET, partial OpenRouter workspace list failures return { keys, workspaceErrors }, but the ?admin=true branch always returns the grouped map and never includes workspaceErrors, so admins can see an incomplete team key list with no warning.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b85c0c. Configure here.

toolRecordId: rec.endpointId,
// Workspace UUID — ties each row to its OpenRouter workspace.
// NULL/undefined for keys in the account default workspace.
organizationId: rec.workspaceId ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The migration logic in insertUsageRecord skips updates for default workspace keys, potentially creating duplicate records due to a mismatch in the ON CONFLICT key logic.
Severity: MEDIUM

Suggested Fix

The UPDATE block in the insertUsageRecord migration logic should also execute when organizationId is undefined or null. This will ensure it correctly finds and migrates pre-existing rows for keys that remain in the default workspace, preventing duplicate insertions.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/sync/openrouter.ts#L239

Potential issue: The migration logic within `insertUsageRecord` is designed to handle
transitions from null to a UUID for `organizationId`. However, when
`syncOpenRouterUsage` calls it with `organizationId: undefined` for keys remaining in
the default workspace, the `UPDATE` logic is skipped because `if
(record.organizationId)` evaluates to false. The code then proceeds directly to an
`INSERT`. The `ON CONFLICT` check uses `COALESCE(organization_id, '')`, which resolves
to `''` for the new record. If a pre-existing, backfilled record for the same key has a
non-null `organization_id`, its conflict key will be different, causing the conflict to
be missed and a duplicate record to be created.

Three small UI fixes on /api-keys:

- Secret-key modal: the revealed key could bleed past the modal edge
  because <pre> does not wrap by default; add whitespace-pre-wrap so
  break-all can actually wrap the long key string.
- Create form: the Create Key button broke onto two lines when the
  workspace selector was present; make it whitespace-nowrap/flex-shrink-0,
  let the name input flex, and widen the form container to max-w-2xl
  when the selector is shown.
- Drop the redundant 'Provisioning key with your management key.'
  notice above the create form (and the now-unused Shield import).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
);
--> statement-breakpoint
ALTER TABLE "openrouter_keys" ADD COLUMN "workspace_id" varchar(64);--> statement-breakpoint
ALTER TABLE "openrouter_keys" DROP COLUMN "workspace"; No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspace column dropped without backfill

High Severity

Migration 0020 adds nullable workspace_id and immediately drops workspace without copying prior values. Keys provisioned between migrations 0019 and 0020 lose workspace context in the database, so listing and sync may query the wrong OpenRouter workspace and omit or mis-attribute keys that live outside the account default workspace.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57a789a. Configure here.

Comment thread src/lib/sync/openrouter.ts
Comment thread src/lib/openrouter.ts
Makes it clear which parts of the page are admin-only:
- View toggle (My Keys / All Keys) in the header gets an amber 'admin' badge beside it
- Delete button on each key row shows an inline 'admin' badge
- Workspaces section label includes the badge

Single AdminBadge component defined once in the page function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 6 total unresolved issues (including 5 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cd434ff. Configure here.

Comment thread scripts/cli/openrouter.ts
KeyRows, AllKeysList, and AdminBadge were defined as functions/arrow
functions inside the ApiKeysPage component body. React treats inline
function definitions as new component types on each render, so every
keystroke in the key-name input caused these components to unmount and
remount, producing a visible list flash.

Move all three to module scope. KeyRows and AllKeysList now receive the
state/callbacks they need via explicit props (keyRowProps spread),
eliminating the closures and the remount problem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant