From d0fdcb40412a347778499bdf8c660061ee27cff2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 14 Jun 2026 12:40:03 +0700 Subject: [PATCH] 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-. - 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. --- .env.example | 3 + README.md | 1 + lib/keys/mint-key.js | 16 +++- lib/openrouter/create-key-request-body.js | 12 ++- lib/openrouter/provisioning-client.js | 8 +- .../phase-01-implement.md | 59 ++++++++++++ .../phase-02-verify-docs.md | 45 +++++++++ .../plan.md | 55 +++++++++++ ...ter-workspace-scoped-key-minting-report.md | 93 +++++++++++++++++++ tests/provisioning-client.test.js | 21 +++++ 10 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-01-implement.md create mode 100644 plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-02-verify-docs.md create mode 100644 plans/260614-1224-openrouter-workspace-scoped-key-minting/plan.md create mode 100644 plans/reports/from-researcher-to-planner-260614-1224-openrouter-workspace-scoped-key-minting-report.md diff --git a/.env.example b/.env.example index d1be168..f002967 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.md b/README.md index 0cc41e6..4a99431 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/lib/keys/mint-key.js b/lib/keys/mint-key.js index 414b4a4..c879be4 100644 --- a/lib/keys/mint-key.js +++ b/lib/keys/mint-key.js @@ -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. * diff --git a/lib/openrouter/create-key-request-body.js b/lib/openrouter/create-key-request-body.js index 4b51fad..acb3fdd 100644 --- a/lib/openrouter/create-key-request-body.js +++ b/lib/openrouter/create-key-request-body.js @@ -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} */ -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; } diff --git a/lib/openrouter/provisioning-client.js b/lib/openrouter/provisioning-client.js index d0265d8..d4f609e 100644 --- a/lib/openrouter/provisioning-client.js +++ b/lib/openrouter/provisioning-client.js @@ -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} */ -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) { diff --git a/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-01-implement.md b/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-01-implement.md new file mode 100644 index 0000000..082aff3 --- /dev/null +++ b/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-01-implement.md @@ -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. diff --git a/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-02-verify-docs.md b/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-02-verify-docs.md new file mode 100644 index 0000000..77b0c7c --- /dev/null +++ b/plans/260614-1224-openrouter-workspace-scoped-key-minting/phase-02-verify-docs.md @@ -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. diff --git a/plans/260614-1224-openrouter-workspace-scoped-key-minting/plan.md b/plans/260614-1224-openrouter-workspace-scoped-key-minting/plan.md new file mode 100644 index 0000000..b0f4290 --- /dev/null +++ b/plans/260614-1224-openrouter-workspace-scoped-key-minting/plan.md @@ -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. diff --git a/plans/reports/from-researcher-to-planner-260614-1224-openrouter-workspace-scoped-key-minting-report.md b/plans/reports/from-researcher-to-planner-260614-1224-openrouter-workspace-scoped-key-minting-report.md new file mode 100644 index 0000000..98173c9 --- /dev/null +++ b/plans/reports/from-researcher-to-planner-260614-1224-openrouter-workspace-scoped-key-minting-report.md @@ -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). diff --git a/tests/provisioning-client.test.js b/tests/provisioning-client.test.js index 24c6e37..784e340 100644 --- a/tests/provisioning-client.test.js +++ b/tests/provisioning-client.test.js @@ -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",