Skip to content

feat: self-serve OpenRouter API key provisioning#69

Closed
HazAT wants to merge 13 commits into
mainfrom
openrouter-keys
Closed

feat: self-serve OpenRouter API key provisioning#69
HazAT wants to merge 13 commits into
mainfrom
openrouter-keys

Conversation

@HazAT

@HazAT HazAT commented Jun 8, 2026

Copy link
Copy Markdown
Member

See #71

@sentry-warden sentry-warden 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.

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
  • postHandler in src/app/api/openrouter/keys/route.ts authenticates the caller but applies no per-user key-count cap and no per-request rate limit.
  • createOpenRouterKey is called with only name — no limit, limitReset, or expiresAt fields — so each provisioned key has unbounded spending.
  • The DB insert in openrouterKeys records 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

Comment on lines +292 to +295
});

export const PATCH = wrapRouteHandlerWithSentry(patchHandler, {
method: 'PATCH',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 calling createOpenRouterKey.
  • createOpenRouterKey is invoked without a limit parameter (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); the wrapRouteHandlerWithSentry wrapper adds only observability, not throttling.
  • A single authenticated engineer can loop POST /api/openrouter/keys to 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:38
  • src/app/api-keys/page.tsx:133
  • src/app/api/openrouter/keys/route.ts:139-175
  • src/lib/openrouter.ts:60-72

Identified by Warden security-review · CJ4-HR7

@HazAT HazAT marked this pull request as ready for review June 8, 2026 15:17
Comment thread src/app/api-keys/page.tsx
setError(err instanceof Error ? err.message : 'Failed to load keys');
} finally {
setLoading(false);
}

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 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.

Fix in Cursor Fix in Web

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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c508f7a. Configure here.


const keys = listResponse.data
.map((item) => normalizeListItem(item))
.filter((key) => dbRowByHash.has(key.hash));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c508f7a. Configure here.

Comment thread src/app/api/openrouter/keys/route.ts
return NextResponse.json({ error: 'Failed to remove key mapping' }, { status: 500 });
}

return NextResponse.json({ success: true });

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 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e3cd79. Configure here.

const userEmail = normalizeEmail(session?.user?.email);
if (!userEmail) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e3cd79. Configure here.

Comment thread package.json Outdated
HazAT and others added 6 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.
@HazAT HazAT force-pushed the openrouter-keys branch from 4e3cd79 to a763192 Compare June 9, 2026 08:45
Comment thread src/app/api-keys/page.tsx
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
env_key = "OPENROUTER_API_KEY"
wire_api = "chat"`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a763192. Configure here.

Comment thread src/app/api-keys/page.tsx
Comment on lines +155 to +157
const isAdminUser = await loadAdminKeys();
setIsAdmin(isAdminUser);
await loadMyKeys();

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: 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.

HazAT and others added 7 commits June 9, 2026 11:29
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 9, 2026 12:03pm

Request Review

@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 7 total unresolved issues (including 6 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 b3d0ba0. Configure here.

}

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 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3d0ba0. Configure here.

@HazAT HazAT closed this Jun 9, 2026
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