feat: self-serve OpenRouter API key provisioning#69
Conversation
There was a problem hiding this comment.
Unlimited OpenRouter key creation with no spending cap enables unbounded financial abuse
Any authenticated user can call POST /api/openrouter/keys an unlimited number of times, each time creating an OpenRouter API key with no spending limit, allowing a compromised or malicious account to provision unbounded paid API capacity.
Evidence
postHandlerinsrc/app/api/openrouter/keys/route.tsauthenticates the caller but applies no per-user key-count cap and no per-request rate limit.createOpenRouterKeyis called with onlyname— nolimit,limitReset, orexpiresAtfields — so each provisioned key has unbounded spending.- The DB insert in
openrouterKeysrecords the mapping but imposes no unique or count constraint per email beyond what Drizzle allows. - An authenticated attacker can loop POST requests to provision many unlimited-spend keys, driving up the organisation's OpenRouter bill with no server-side guard stopping them.
Identified by Warden security-review
| }); | ||
|
|
||
| export const PATCH = wrapRouteHandlerWithSentry(patchHandler, { | ||
| method: 'PATCH', |
There was a problem hiding this comment.
No per-user key creation limit allows authenticated users to provision unlimited unmetered OpenRouter API keys
Any authenticated user can call POST /api/openrouter/keys an unlimited number of times; each call provisions a new OpenRouter key with no spend limit, allowing unbounded LLM spend billed to the company's OpenRouter account. Add a per-user key count check before creation, and set a default limit in createOpenRouterKey to cap per-key spend.
Evidence
postHandler(line ~145) requires only a valid session; no per-user key count is checked before callingcreateOpenRouterKey.createOpenRouterKeyis invoked without alimitparameter (line ~167), so every provisioned key carries the OpenRouter default of no spend ceiling.- There is no rate-limiting middleware applied to any route in the codebase (confirmed by grep across
src/**/*.ts); thewrapRouteHandlerWithSentrywrapper adds only observability, not throttling. - A single authenticated engineer can loop
POST /api/openrouter/keysto create hundreds of keys, each distributable for arbitrary LLM spending charged to the company account.
Also found at 4 additional locations
src/lib/schema.ts:38src/app/api-keys/page.tsx:133src/app/api/openrouter/keys/route.ts:139-175src/lib/openrouter.ts:60-72
Identified by Warden security-review · CJ4-HR7
| setError(err instanceof Error ? err.message : 'Failed to load keys'); | ||
| } finally { | ||
| setLoading(false); | ||
| } |
There was a problem hiding this comment.
Admin tab resets after refresh
Medium Severity
Each call to loadKeys sets view to 'all' whenever the user is an admin, including after create, disable, enable, or delete. An admin who switched to “My Keys” is sent back to “All Keys” on every refresh triggered by those actions.
Reviewed by Cursor Bugbot for commit c508f7a. Configure here.
| try { | ||
| await db.delete(openrouterKeys).where(eq(openrouterKeys.hash, hash)); | ||
| } catch { | ||
| return NextResponse.json({ error: 'Failed to remove key mapping' }, { status: 500 }); |
There was a problem hiding this comment.
Delete leaves orphan DB rows
Medium Severity
Admin DELETE removes the key on OpenRouter first, then deletes the openrouter_keys row. If the database step fails after OpenRouter succeeds, the API returns 500 while the mapping remains. Retries fail on OpenRouter, and the row never appears in list views, so it cannot be cleared from the UI.
Reviewed by Cursor Bugbot for commit c508f7a. Configure here.
|
|
||
| const keys = listResponse.data | ||
| .map((item) => normalizeListItem(item)) | ||
| .filter((key) => dbRowByHash.has(key.hash)); |
There was a problem hiding this comment.
List keys ignores pagination
Medium Severity
GET calls listOpenRouterKeys once with no offset loop, then keeps only hashes that appear in that response. OpenRouter’s list endpoint is paginated; mappings beyond the first page never match and disappear from “My Keys” and admin views despite valid DB rows.
Reviewed by Cursor Bugbot for commit c508f7a. Configure here.
| return NextResponse.json({ error: 'Failed to remove key mapping' }, { status: 500 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ success: true }); |
There was a problem hiding this comment.
Admin deletes untracked OpenRouter keys
High Severity
The admin DELETE handler calls deleteOpenRouterKey before checking that hash exists in openrouter_keys. Any management-account key hash can be removed at OpenRouter while the API still returns { success: true } when no Abacus row was deleted.
Reviewed by Cursor Bugbot for commit 4e3cd79. Configure here.
| const userEmail = normalizeEmail(session?.user?.email); | ||
| if (!userEmail) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } |
There was a problem hiding this comment.
Auth bypass yields 401
Medium Severity
With AUTH_BYPASS_LOCAL, checkAuth passes but getSession is always null, so these handlers return 401 when session?.user?.email is missing—unlike other API routes that only use checkAuth and work under bypass.
Reviewed by Cursor Bugbot for commit 4e3cd79. Configure here.
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.
| name = "OpenRouter" | ||
| base_url = "https://openrouter.ai/api/v1" | ||
| env_key = "OPENROUTER_API_KEY" | ||
| wire_api = "chat"`; |
There was a problem hiding this comment.
Setup snippets expose new secret
Medium Severity
After a successful create, revealedKey is wired into the always-visible Claude Code and Codex setup snippets via setupKey, so the full secret appears outside the one-time modal and stays in the DOM for copy/view-source even while the modal is open.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a763192. Configure here.
| const isAdminUser = await loadAdminKeys(); | ||
| setIsAdmin(isAdminUser); | ||
| await loadMyKeys(); |
There was a problem hiding this comment.
Bug: For admin users, a failure in loadAdminKeys due to an upstream outage throws an error, preventing the subsequent loadMyKeys call from executing and displaying their personal keys.
Severity: MEDIUM
Suggested Fix
Decouple the loading of admin keys and personal keys to ensure a failure in one does not block the other. Use Promise.allSettled to run loadAdminKeys and loadMyKeys in parallel, then process their results independently. This ensures that even if the admin key fetch fails, the user's personal keys can still be loaded and displayed.
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-keys/page.tsx#L155-L157
Potential issue: For admin users, the `loadKeys` function first attempts to fetch all
keys via `loadAdminKeys`. If the upstream OpenRouter API is unavailable (e.g., due to a
rate limit or 5xx error), the backend endpoint returns a 502 or 503 status. The frontend
`loadAdminKeys` function treats this as a fatal error and throws an exception. This
exception is caught, but it prevents the subsequent `loadMyKeys` call from executing. As
a result, admin users are unable to view their own personal API keys during a transient
upstream service disruption, even though their own keys could be fetched successfully.
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 7 total unresolved issues (including 6 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b3d0ba0. Configure here.
| } | ||
|
|
||
| return NextResponse.json(normalizeListItem(updated.data)); | ||
| } |
There was a problem hiding this comment.
Offboarded keys can be re-enabled
High Severity
The offboarding cron sets revoked_at and disables keys on OpenRouter, but PATCH does not treat revoked keys differently. An account with a still-valid session can call PATCH with disabled: false and turn the key back on at OpenRouter while revoked_at stays set, undermining automatic offboarding.
Reviewed by Cursor Bugbot for commit b3d0ba0. Configure here.


See #71