mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-09-05 08:17:40 +00:00
feat(keys): create minted OpenRouter keys in a configured workspace
Pass create-key workspace_id (from OPENROUTER_WORKSPACE_ID, defaulting to the project workspace) so minted keys land in the intended workspace instead of the management key's default. Rename key to non-PII llmapikey/gh-<id>. - buildCreateKeyBody emits workspace_id only when set (omission keeps default). - createKey threads workspaceId; mintAndPersist sources it from env. - Test asserts workspace_id presence/omission; document the new env var.
This commit is contained in:
@@ -22,6 +22,9 @@ POSTGRES_URL=postgresql://postgres.your-ref:password@aws-0-region.pooler.supabas
|
||||
# Master management/provisioning key used to mint per-user keys. NEVER expose to
|
||||
# the client.
|
||||
OPENROUTER_MANAGEMENT_KEY=sk-or-v1-provisioning-...
|
||||
# Workspace the minted keys are created in (create-key `workspace_id`). Omit to
|
||||
# fall back to the management key's default workspace.
|
||||
OPENROUTER_WORKSPACE_ID=33179556-3ab3-40a4-af8b-211d322aa94e
|
||||
|
||||
# ---- Provisioning controls (server-only) ----
|
||||
# Feature flag: live key minting is OFF until OpenRouter ToS gate (Phase 1)
|
||||
|
||||
@@ -61,6 +61,7 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed
|
||||
| `AUTH_SESSION_SECRET` | Session JWT signing secret, ≥32 bytes (`openssl rand -base64 48`) |
|
||||
| `POSTGRES_URL` | Supabase **transaction pooler** string (server-only; provisioned by the Supabase Vercel integration) |
|
||||
| `OPENROUTER_MANAGEMENT_KEY` | Master management/provisioning key (server-only) |
|
||||
| `OPENROUTER_WORKSPACE_ID` | Workspace minted keys are created in (create-key `workspace_id`); omit for the management key's default |
|
||||
| `PROVISIONING_ENABLED` | `false` until Phase 1 ToS gate clears |
|
||||
| `MAX_TOTAL_KEYS` | Kill-switch: stop minting past N active keys |
|
||||
| `KEY_DAILY_LIMIT_USD` | Per-key daily cap sent to OpenRouter |
|
||||
|
||||
+15
-1
@@ -29,11 +29,12 @@ export async function mintAndPersist(reservedId, githubUserId) {
|
||||
let mint;
|
||||
try {
|
||||
mint = await createKey({
|
||||
name: `llmapikey:${githubUserId}`, // opaque numeric id — no PII into OpenRouter logs
|
||||
name: `llmapikey/gh-${githubUserId}`, // opaque numeric id — no PII into OpenRouter logs
|
||||
limitUsd: numEnv("KEY_DAILY_LIMIT_USD", 10),
|
||||
resetPeriod: "daily",
|
||||
includeByok: true,
|
||||
expiresAt: expiryIso(numEnv("KEY_EXPIRY_DAYS", 90)),
|
||||
workspaceId: workspaceId(),
|
||||
});
|
||||
} catch {
|
||||
await repo.deletePending(reservedId); // free the reservation; no orphan key exists
|
||||
@@ -56,6 +57,19 @@ export async function mintAndPersist(reservedId, githubUserId) {
|
||||
return { status: "created", rawKey: mint.key, keyHint: last4(mint.key) };
|
||||
}
|
||||
|
||||
/** Default OpenRouter workspace for minted keys (env-overridable). */
|
||||
const DEFAULT_WORKSPACE_ID = "33179556-3ab3-40a4-af8b-211d322aa94e";
|
||||
|
||||
/**
|
||||
* Workspace the minted keys are created in. Override via OPENROUTER_WORKSPACE_ID;
|
||||
* the default places keys in the project's intended workspace.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function workspaceId() {
|
||||
return process.env.OPENROUTER_WORKSPACE_ID || DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a numeric env var, falling back if missing or malformed.
|
||||
*
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
* `server-only` guard — so it is unit-testable under plain node.
|
||||
*
|
||||
* Fields are snake_case per the OpenRouter API ref (`limit_reset`,
|
||||
* `include_byok_in_limit`, `expires_at`).
|
||||
* `include_byok_in_limit`, `expires_at`, `workspace_id`).
|
||||
*
|
||||
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string }} params
|
||||
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string, workspaceId?: string }} params
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt }) {
|
||||
return {
|
||||
export function buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt, workspaceId }) {
|
||||
const body = {
|
||||
name,
|
||||
limit: limitUsd,
|
||||
limit_reset: resetPeriod,
|
||||
include_byok_in_limit: includeByok,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
// Place the key in a specific workspace; omitted → OpenRouter uses the
|
||||
// management key's default workspace.
|
||||
if (workspaceId) body.workspace_id = workspaceId;
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -25,19 +25,19 @@ export class ProvisioningError extends Error {
|
||||
|
||||
/**
|
||||
* Mint a new provisioned key. Request fields are snake_case per the OpenRouter
|
||||
* API ref (`limit_reset`, `include_byok_in_limit`).
|
||||
* API ref (`limit_reset`, `include_byok_in_limit`, `workspace_id`).
|
||||
*
|
||||
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string }} params
|
||||
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string, workspaceId?: string }} params
|
||||
* @returns {Promise<CreateKeyResult>}
|
||||
*/
|
||||
export async function createKey({ name, limitUsd, resetPeriod, includeByok, expiresAt }) {
|
||||
export async function createKey({ name, limitUsd, resetPeriod, includeByok, expiresAt, workspaceId }) {
|
||||
const res = await fetch(OPENROUTER_KEYS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${requireProvisioningKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt })),
|
||||
body: JSON.stringify(buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt, workspaceId })),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Implement
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 30m
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Implement
|
||||
|
||||
## Overview
|
||||
Thread a workspace id and a non-PII name into the create-key call so minted keys land in the
|
||||
target workspace.
|
||||
|
||||
## Requirements
|
||||
- Functional: every minted key carries `workspace_id` = `OPENROUTER_WORKSPACE_ID` (default
|
||||
`33179556-3ab3-40a4-af8b-211d322aa94e`) and `name` = `llmapikey/gh-${githubUserId}`.
|
||||
- Non-functional: no new deps; raw `fetch` retained; numeric-id-only name (no login/PII).
|
||||
|
||||
## Architecture
|
||||
Body shaping is pure (`create-key-request-body.js`); the HTTP client passes fields through
|
||||
(`provisioning-client.js`); the workspace id + name are decided in the orchestrator
|
||||
(`mint-key.js`) where the other env-driven controls already live. Data flow:
|
||||
`mintAndPersist` → `createKey({..., workspaceId})` → `buildCreateKeyBody({..., workspaceId})`
|
||||
→ `{ ..., workspace_id }`.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `lib/openrouter/create-key-request-body.js` — accept `workspaceId`, emit
|
||||
`workspace_id` (snake_case) only when set.
|
||||
- Modify: `lib/openrouter/provisioning-client.js` — `createKey` accepts `workspaceId`, forwards
|
||||
it to `buildCreateKeyBody`.
|
||||
- Modify: `lib/keys/mint-key.js` — read `OPENROUTER_WORKSPACE_ID` (default UUID); pass
|
||||
`workspaceId`; change `name` to `llmapikey/gh-${githubUserId}`.
|
||||
|
||||
## Implementation Steps
|
||||
1. `create-key-request-body.js`: add `workspaceId` to the param object; in the returned object
|
||||
add `workspace_id: workspaceId` — include the field only when `workspaceId` is truthy
|
||||
(keep body clean when unset, so omission falls back to the default workspace). Update JSDoc.
|
||||
2. `provisioning-client.js`: add `workspaceId` to `createKey`'s destructured params and to its
|
||||
JSDoc typedef; pass it into `buildCreateKeyBody({ ..., workspaceId })`.
|
||||
3. `mint-key.js`:
|
||||
- Add a small helper or inline: `const workspaceId = process.env.OPENROUTER_WORKSPACE_ID ?? "33179556-3ab3-40a4-af8b-211d322aa94e";`
|
||||
- Pass `workspaceId` in the `createKey({...})` call.
|
||||
- Change `name` from `llmapikey:${githubUserId}` to `llmapikey/gh-${githubUserId}`.
|
||||
- Keep the inline comment that the name is an opaque numeric id (no PII).
|
||||
4. `npm run build` — must compile clean.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `buildCreateKeyBody` emits `workspace_id` when `workspaceId` is provided, omits it otherwise.
|
||||
- [ ] `createKey` forwards `workspaceId`; `mintAndPersist` sources it from `OPENROUTER_WORKSPACE_ID` with the UUID default.
|
||||
- [ ] Key `name` is `llmapikey/gh-${githubUserId}` (no login/PII).
|
||||
- [ ] `npm run build` clean.
|
||||
|
||||
## Risk Assessment
|
||||
- **Invalid/inaccessible workspace_id** → OpenRouter returns 4xx; existing `ProvisioningError`
|
||||
+ compensating `deletePending` already handle create failure gracefully. Mitigation: one-time
|
||||
manual probe at deploy (confirm the management key's org owns the workspace).
|
||||
- **Stale default if UUID ever changes** → it's env-overridable; the default is a fallback only.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Verify & Docs
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 20m
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Verify & Docs
|
||||
|
||||
## Overview
|
||||
Lock the behavior with a unit test and document the new env var.
|
||||
|
||||
## Requirements
|
||||
- Functional: test proves `workspace_id` flows into the request body; docs list `OPENROUTER_WORKSPACE_ID`.
|
||||
- Non-functional: tests pass under plain `node --test`; no secret committed.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `tests/provisioning-client.test.js` — add a `buildCreateKeyBody` case asserting
|
||||
`workspace_id` present when `workspaceId` given, and absent when omitted.
|
||||
- Modify: `.env.example` — add `OPENROUTER_WORKSPACE_ID` under the OpenRouter section with the
|
||||
default UUID + comment (server-only, not secret but env-driven).
|
||||
- Modify: `README.md` — add `OPENROUTER_WORKSPACE_ID` row to the env table; one line noting
|
||||
minted keys are placed in this workspace.
|
||||
|
||||
## Implementation Steps
|
||||
1. `tests/provisioning-client.test.js`: import `buildCreateKeyBody` (already exercised here);
|
||||
add two assertions — with `workspaceId: "ws-uuid"` → body has `workspace_id: "ws-uuid"`;
|
||||
without it → `"workspace_id" in body === false`.
|
||||
2. `.env.example`: under `# ---- OpenRouter ...`, add
|
||||
`# Workspace the minted keys are created in (create-key workspace_id).`
|
||||
`OPENROUTER_WORKSPACE_ID=33179556-3ab3-40a4-af8b-211d322aa94e`.
|
||||
3. `README.md`: env table row `| OPENROUTER_WORKSPACE_ID | Workspace minted keys are created in (create-key workspace_id) |`.
|
||||
4. `npm test` — all green. `npm run build` — clean.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] New test asserts presence + omission of `workspace_id`; `npm test` green.
|
||||
- [ ] `.env.example` + `README.md` document `OPENROUTER_WORKSPACE_ID`.
|
||||
- [ ] `npm run build` clean.
|
||||
|
||||
## Risk Assessment
|
||||
- **Test coupling** — assert only the `workspace_id` field, not the whole body, to avoid
|
||||
brittleness against unrelated body changes.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: OpenRouter workspace-scoped key minting
|
||||
description: >-
|
||||
Mint per-user OpenRouter keys into a specific workspace via the create-key
|
||||
workspace_id field, with a non-PII name. Config-driven
|
||||
(OPENROUTER_WORKSPACE_ID).
|
||||
status: completed
|
||||
priority: P2
|
||||
branch: master
|
||||
tags:
|
||||
- openrouter
|
||||
- provisioning
|
||||
- keys
|
||||
- config
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-06-14T05:28:46.125Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# OpenRouter workspace-scoped key minting
|
||||
|
||||
## Overview
|
||||
|
||||
Minted keys currently land in the management key's **default** workspace because the
|
||||
create-key request omits `workspace_id`. OpenRouter's `POST /api/v1/keys` accepts an optional
|
||||
`workspace_id` (UUID); management keys act account-wide across workspaces (research report:
|
||||
`plans/reports/from-researcher-to-planner-260614-1224-openrouter-workspace-scoped-key-minting-report.md`).
|
||||
Add `workspace_id` to the request (sourced from a new `OPENROUTER_WORKSPACE_ID` env, default
|
||||
`33179556-3ab3-40a4-af8b-211d322aa94e`) and tidy the key name to a non-PII
|
||||
`llmapikey/gh-${githubUserId}`. Small, config-driven, no new dependency.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **No SDK** — the official `@openrouter/sdk` (v0.12 beta) exposes only `chat`/`byok`/`files`,
|
||||
**no key-management resource**. Keep the existing raw `fetch`, which already matches the API
|
||||
ref. (Verified during research.)
|
||||
- **Workspace id is config, not hardcoded** — `OPENROUTER_WORKSPACE_ID` env with the UUID as
|
||||
default. Parallels `KEY_DAILY_LIMIT_USD` etc.; lets staging/prod differ; keeps the literal
|
||||
out of code.
|
||||
- **Name stays non-PII** — anchor on the numeric immutable GitHub id; never the login.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Implement](./phase-01-implement.md) | Completed |
|
||||
| 2 | [Verify & Docs](./phase-02-verify-docs.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Reads the unchanged identity contract; no impact on the auth migration plan.
|
||||
- Out of scope: live DB error `relation "llmapikey.api_keys" does not exist` is an unapplied
|
||||
migration (`supabase/migrations/0001_...up.sql`), not a code issue.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# Research Report: OpenRouter Workspace-Scoped Key Minting
|
||||
|
||||
_Conducted: 2026-06-14 12:24 (+07). Sources: 5 (OpenRouter official docs)._
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Creating a minted key inside a specific workspace is a **one-field change**: OpenRouter's
|
||||
`POST /api/v1/keys` accepts an optional **`workspace_id`** (UUID) body field. Set it to
|
||||
`33179556-3ab3-40a4-af8b-211d322aa94e` and the key lands in that workspace; omit it and the
|
||||
key goes to the management key's default workspace (current behavior — why keys aren't where
|
||||
expected). No header, no separate endpoint, no provisioning-key reissue needed: **management
|
||||
keys operate at the account level across all workspaces**, so the existing master
|
||||
`OPENROUTER_MANAGEMENT_KEY` can target any workspace in the org.
|
||||
|
||||
Key `name` is a required string (≥1 char) with no documented format constraints. Current
|
||||
`llmapikey:${githubUserId}` is already non-PII (numeric immutable id) and fine; only minor
|
||||
naming polish is optional.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. `POST /api/v1/keys` — full request schema
|
||||
|
||||
| Field | Type | Req | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string (≥1) | **Yes** | Display name for the key |
|
||||
| `limit` | number \| null | No | Spending limit (USD) |
|
||||
| `limit_reset` | `daily`\|`weekly`\|`monthly` \| null | No | Resets at midnight UTC |
|
||||
| `include_byok_in_limit` | boolean | No | Count BYOK usage toward limit |
|
||||
| `expires_at` | ISO 8601 UTC datetime \| null | No | Must be UTC (non-UTC rejected) |
|
||||
| `creator_user_id` | string \| null | No | Only meaningful for org-owned keys |
|
||||
| **`workspace_id`** | **string (UUID)** | **No** | **Workspace to create the key in. Defaults to default workspace if omitted.** |
|
||||
|
||||
Current code (`create-key-request-body.js`) sends `name, limit, limit_reset,
|
||||
include_byok_in_limit, expires_at` — correct, just missing `workspace_id`.
|
||||
|
||||
### 2. Workspace scoping mechanism
|
||||
|
||||
- Every API key lives in a workspace. The owning workspace is determined by `workspace_id`
|
||||
if provided, else the authenticated (management) key's default workspace.
|
||||
- "Management keys operate at the account level and can be used to perform administrative
|
||||
actions across **all workspaces** via the management API." → no per-workspace management
|
||||
key needed; the master key targets `workspace_id` directly.
|
||||
|
||||
### 3. Key naming
|
||||
|
||||
- Only constraint found: required, ≥1 char. No length/charset rules documented.
|
||||
- Best practice: non-PII + identifiable for reconciliation. Current `llmapikey:${githubUserId}`
|
||||
(numeric immutable GitHub id) already satisfies this. The numeric id is the right anchor
|
||||
(login is mutable/PII-ish). Optional polish: namespace clarity, e.g.
|
||||
`llmapikey/gh-${githubUserId}`. Avoid embedding the GitHub login.
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
1. Add `workspace_id` to `buildCreateKeyBody(...)` output (snake_case, matches API).
|
||||
2. Thread a `workspaceId` param through `createKey()` → `mintAndPersist()`. Source it from a
|
||||
new env `OPENROUTER_WORKSPACE_ID` (default to the given UUID) rather than hardcoding — keeps
|
||||
it config, parallels the other key controls, and lets staging/prod differ.
|
||||
3. Keep `name` non-PII; optionally tidy to `llmapikey/gh-${githubUserId}`.
|
||||
4. Unit-test `buildCreateKeyBody` includes `workspace_id` (extend existing test).
|
||||
|
||||
### Example request body (target)
|
||||
```json
|
||||
{
|
||||
"name": "llmapikey/gh-12345",
|
||||
"limit": 10,
|
||||
"limit_reset": "daily",
|
||||
"include_byok_in_limit": true,
|
||||
"expires_at": "2026-09-12T00:00:00.000Z",
|
||||
"workspace_id": "33179556-3ab3-40a4-af8b-211d322aa94e"
|
||||
}
|
||||
```
|
||||
|
||||
### Common pitfalls
|
||||
- Omitting `workspace_id` silently routes to the default workspace (the current symptom).
|
||||
- `expires_at` MUST be UTC ISO 8601 — non-UTC rejected (current `toISOString()` is UTC ✓).
|
||||
- Don't conflate `creator_user_id` with `workspace_id`; the former is org-member attribution,
|
||||
not workspace placement, and is irrelevant here.
|
||||
|
||||
## Resources & References
|
||||
|
||||
### Official Documentation
|
||||
- [Create a new API key](https://openrouter.ai/docs/api/api-reference/api-keys/create-keys)
|
||||
- [Provisioning API Keys](https://openrouter.ai/docs/features/provisioning-api-keys)
|
||||
- [Management API Keys](https://openrouter.ai/docs/guides/overview/auth/management-api-keys)
|
||||
- [Workspaces](https://openrouter.ai/docs/guides/features/workspaces)
|
||||
- [Introducing Workspaces (blog)](https://openrouter.ai/blog/introducing-workspaces/)
|
||||
|
||||
## Unresolved Questions
|
||||
1. Exact API error when `workspace_id` is invalid / not accessible by the management key — docs
|
||||
don't specify (likely 4xx). Plan should handle create-key failure gracefully (already does
|
||||
via `ProvisioningError` → compensating delete). Worth a one-time manual probe at deploy.
|
||||
2. Confirm the master `OPENROUTER_MANAGEMENT_KEY` belongs to the org that owns workspace
|
||||
`33179556-…` (cross-org targeting not documented as supported).
|
||||
@@ -21,6 +21,27 @@ test("create-key body uses OpenRouter snake_case fields", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("create-key body includes workspace_id when given, omits it otherwise", () => {
|
||||
const scoped = buildCreateKeyBody({
|
||||
name: "llmapikey/gh-12345",
|
||||
limitUsd: 10,
|
||||
resetPeriod: "daily",
|
||||
includeByok: true,
|
||||
expiresAt: "2026-09-11T00:00:00.000Z",
|
||||
workspaceId: "33179556-3ab3-40a4-af8b-211d322aa94e",
|
||||
});
|
||||
assert.equal(scoped.workspace_id, "33179556-3ab3-40a4-af8b-211d322aa94e");
|
||||
|
||||
const unscoped = buildCreateKeyBody({
|
||||
name: "x",
|
||||
limitUsd: 5,
|
||||
resetPeriod: "daily",
|
||||
includeByok: false,
|
||||
expiresAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
assert.ok(!("workspace_id" in unscoped));
|
||||
});
|
||||
|
||||
test("create-key body has no camelCase leakage", () => {
|
||||
const body = buildCreateKeyBody({
|
||||
name: "x",
|
||||
|
||||
Reference in New Issue
Block a user