From 95ffc21b6001a56a4f1755e78cb1e8d30baece3d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 22 Mar 2026 15:33:45 -0300 Subject: [PATCH] feat(3.0.0-rc.5): Registered Keys Provisioning API (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete implementation of auto-provisioning API: - DB migration 008: registered_keys, provider_key_limits, account_key_limits - src/lib/db/registeredKeys.ts: full quota enforcement, idempotency, sha256 hashing, budget tracking, window auto-reset - POST /api/v1/registered-keys — issue with quota check - GET /api/v1/registered-keys — list (masked) - GET|DELETE /api/v1/registered-keys/[id] — get/revoke - POST /api/v1/registered-keys/[id]/revoke — explicit revoke - GET /api/v1/quotas/check — pre-validate without issuing - GET|PUT /api/v1/providers/[id]/limits — provider limits CRUD - GET|PUT /api/v1/accounts/[id]/limits — account limits CRUD - POST /api/v1/issues/report — optional GitHub issue reporting (requires GITHUB_ISSUES_REPO + GITHUB_ISSUES_TOKEN env vars) - Exported all from localDb.ts --- CHANGELOG.md | 17 + docs/openapi.yaml | 2 +- package-lock.json | 4 +- package.json | 2 +- src/app/api/v1/accounts/[id]/limits/route.ts | 49 ++ src/app/api/v1/issues/report/route.ts | 123 ++++ src/app/api/v1/providers/[id]/limits/route.ts | 49 ++ src/app/api/v1/quotas/check/route.ts | 33 ++ .../v1/registered-keys/[id]/revoke/route.ts | 21 + src/app/api/v1/registered-keys/[id]/route.ts | 33 ++ src/app/api/v1/registered-keys/route.ts | 118 ++++ src/lib/db/migrations/008_registered_keys.sql | 65 +++ src/lib/db/registeredKeys.ts | 531 ++++++++++++++++++ src/lib/localDb.ts | 24 + 14 files changed, 1067 insertions(+), 4 deletions(-) create mode 100644 src/app/api/v1/accounts/[id]/limits/route.ts create mode 100644 src/app/api/v1/issues/report/route.ts create mode 100644 src/app/api/v1/providers/[id]/limits/route.ts create mode 100644 src/app/api/v1/quotas/check/route.ts create mode 100644 src/app/api/v1/registered-keys/[id]/revoke/route.ts create mode 100644 src/app/api/v1/registered-keys/[id]/route.ts create mode 100644 src/app/api/v1/registered-keys/route.ts create mode 100644 src/lib/db/migrations/008_registered_keys.sql create mode 100644 src/lib/db/registeredKeys.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c3496c11..8154b7456b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ --- +## [3.0.0-rc.5] - 2026-03-22 + +### ✨ New Features + +- **#464** — Registered Keys Provisioning API: auto-issue API keys with per-provider & per-account quota enforcement + - `POST /api/v1/registered-keys` — issue keys with idempotency support + - `GET /api/v1/registered-keys` — list (masked) registered keys + - `GET /api/v1/registered-keys/{id}` — get key metadata + - `DELETE /api/v1/registered-keys/{id}` / `POST ../{id}/revoke` — revoke keys + - `GET /api/v1/quotas/check` — pre-validate before issuing + - `PUT /api/v1/providers/{id}/limits` — set provider issuance limits + - `PUT /api/v1/accounts/{id}/limits` — set account issuance limits + - `POST /api/v1/issues/report` — optional GitHub issue reporting + - DB migration 008: `registered_keys`, `provider_key_limits`, `account_key_limits` tables + +--- + ## [3.0.0-rc.4] - 2026-03-22 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4d2c52ed11..302adb20c5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.0.0-rc.4 + version: 3.0.0-rc.5 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/package-lock.json b/package-lock.json index f02ea1b1f4..dcbab1219f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.0.0-rc.4", + "version": "3.0.0-rc.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.0.0-rc.4", + "version": "3.0.0-rc.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 008d424519..e3e9e265f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.0.0-rc.4", + "version": "3.0.0-rc.5", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/api/v1/accounts/[id]/limits/route.ts b/src/app/api/v1/accounts/[id]/limits/route.ts new file mode 100644 index 0000000000..c7bbcdd6e7 --- /dev/null +++ b/src/app/api/v1/accounts/[id]/limits/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { setAccountKeyLimit, getAccountKeyLimit } from "@/lib/db/registeredKeys"; + +const limitsSchema = z.object({ + maxActiveKeys: z.number().int().positive().nullable().optional(), + dailyIssueLimit: z.number().int().positive().nullable().optional(), + hourlyIssueLimit: z.number().int().positive().nullable().optional(), +}); + +/** + * GET /api/v1/accounts/[id]/limits + * Get the current issuance limits for an account. + */ +export async function GET(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const limits = getAccountKeyLimit(params.id); + return NextResponse.json({ accountId: params.id, limits: limits ?? null }); +} + +/** + * PUT /api/v1/accounts/[id]/limits + * Configure issuance limits for an account. + */ +export async function PUT(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = limitsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + setAccountKeyLimit(params.id, parsed.data); + const updated = getAccountKeyLimit(params.id); + return NextResponse.json({ accountId: params.id, limits: updated }); +} diff --git a/src/app/api/v1/issues/report/route.ts b/src/app/api/v1/issues/report/route.ts new file mode 100644 index 0000000000..11cc130c7b --- /dev/null +++ b/src/app/api/v1/issues/report/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +const reportSchema = z.object({ + title: z.string().min(1).max(300), + provider: z.string().max(80).optional(), + accountId: z.string().max(120).optional(), + requestId: z.string().max(200).optional(), + errorCode: z.string().max(100).optional(), + details: z.record(z.unknown()).optional(), + labels: z.array(z.string().max(50)).optional(), +}); + +/** + * POST /api/v1/issues/report + * + * Optionally report a quota-exceeded or key-issuance failure event to GitHub. + * + * Requires GITHUB_ISSUES_REPO (format: owner/repo) and GITHUB_ISSUES_TOKEN + * environment variables to be set. If not configured, returns 202 (accepted but + * logged only). + */ +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = reportSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const { title, provider, accountId, requestId, errorCode, details, labels = [] } = parsed.data; + + const repo = process.env.GITHUB_ISSUES_REPO; + const token = process.env.GITHUB_ISSUES_TOKEN; + + // ── Structured body for the GitHub issue ── + const issueBody = [ + `## ${errorCode ?? "Key Issuance Event"}`, + "", + "| Field | Value |", + "|-------|-------|", + provider ? `| Provider | \`${provider}\` |` : null, + accountId ? `| Account ID | \`${accountId}\` |` : null, + requestId ? `| Request ID | \`${requestId}\` |` : null, + errorCode ? `| Error Code | \`${errorCode}\` |` : null, + `| Reported At | ${new Date().toISOString()} |`, + "", + details ? "### Details\n```json\n" + JSON.stringify(details, null, 2) + "\n```" : null, + "", + "_Auto-reported by OmniRoute Registered Key Issuer_", + ] + .filter(Boolean) + .join("\n"); + + // ── Log locally regardless ── + console.log( + `[issues/report] title="${title}" errorCode=${errorCode ?? "—"} provider=${provider ?? "—"} accountId=${accountId ?? "—"}` + ); + + if (!repo || !token) { + // No GitHub config — log only + return NextResponse.json( + { + logged: true, + githubIssueCreated: false, + reason: !repo ? "GITHUB_ISSUES_REPO not configured" : "GITHUB_ISSUES_TOKEN not configured", + }, + { status: 202 } + ); + } + + // ── Create GitHub issue ── + try { + const [owner, repoName] = repo.split("/"); + const ghRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: `[Key Issuer] ${title}`, + body: issueBody, + labels: ["key-issuer", "automated", ...labels], + }), + }); + + if (!ghRes.ok) { + const errText = await ghRes.text(); + console.error(`[issues/report] GitHub API error ${ghRes.status}: ${errText}`); + return NextResponse.json( + { logged: true, githubIssueCreated: false, githubError: ghRes.status }, + { status: 207 } + ); + } + + const ghData = await ghRes.json(); + return NextResponse.json({ + logged: true, + githubIssueCreated: true, + githubIssueUrl: ghData.html_url, + githubIssueNumber: ghData.number, + }); + } catch (err) { + console.error("[issues/report] GitHub fetch failed:", err); + return NextResponse.json( + { logged: true, githubIssueCreated: false, error: "GitHub request failed" }, + { status: 207 } + ); + } +} diff --git a/src/app/api/v1/providers/[id]/limits/route.ts b/src/app/api/v1/providers/[id]/limits/route.ts new file mode 100644 index 0000000000..a249233593 --- /dev/null +++ b/src/app/api/v1/providers/[id]/limits/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { setProviderKeyLimit, getProviderKeyLimit } from "@/lib/db/registeredKeys"; + +const limitsSchema = z.object({ + maxActiveKeys: z.number().int().positive().nullable().optional(), + dailyIssueLimit: z.number().int().positive().nullable().optional(), + hourlyIssueLimit: z.number().int().positive().nullable().optional(), +}); + +/** + * GET /api/v1/providers/[id]/limits + * Get the current issuance limits for a provider. + */ +export async function GET(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const limits = getProviderKeyLimit(params.id); + return NextResponse.json({ provider: params.id, limits: limits ?? null }); +} + +/** + * PUT /api/v1/providers/[id]/limits + * Configure issuance limits for a provider. + */ +export async function PUT(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = limitsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + setProviderKeyLimit(params.id, parsed.data); + const updated = getProviderKeyLimit(params.id); + return NextResponse.json({ provider: params.id, limits: updated }); +} diff --git a/src/app/api/v1/quotas/check/route.ts b/src/app/api/v1/quotas/check/route.ts new file mode 100644 index 0000000000..d0159daf31 --- /dev/null +++ b/src/app/api/v1/quotas/check/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { checkQuota } from "@/lib/db/registeredKeys"; + +/** + * GET /api/v1/quotas/check?provider=&accountId= + * + * Check if a new registered key can be issued for the given provider/account + * without actually issuing one. Use this to pre-validate before POST /registered-keys. + */ +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider") ?? ""; + const accountId = searchParams.get("accountId") ?? ""; + + try { + const result = checkQuota(provider, accountId); + return NextResponse.json({ + allowed: result.allowed, + ...(result.errorCode ? { errorCode: result.errorCode, reason: result.errorMessage } : {}), + provider: provider || null, + accountId: accountId || null, + checkedAt: new Date().toISOString(), + }); + } catch (err) { + console.error("[quotas/check] error:", err); + return NextResponse.json({ error: "Quota check failed" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/registered-keys/[id]/revoke/route.ts b/src/app/api/v1/registered-keys/[id]/revoke/route.ts new file mode 100644 index 0000000000..d912c7d1e2 --- /dev/null +++ b/src/app/api/v1/registered-keys/[id]/revoke/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { revokeRegisteredKey } from "@/lib/db/registeredKeys"; + +/** + * POST /api/v1/registered-keys/[id]/revoke + * + * Explicit revoke endpoint (supports clients that cannot issue DELETE requests). + */ +export async function POST(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const revoked = revokeRegisteredKey(params.id); + if (!revoked) { + return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 }); + } + + return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() }); +} diff --git a/src/app/api/v1/registered-keys/[id]/route.ts b/src/app/api/v1/registered-keys/[id]/route.ts new file mode 100644 index 0000000000..81f6ac22a3 --- /dev/null +++ b/src/app/api/v1/registered-keys/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getRegisteredKey, revokeRegisteredKey } from "@/lib/db/registeredKeys"; + +// ─── GET /api/v1/registered-keys/[id] ──────────────────────────────────────── + +export async function GET(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const key = getRegisteredKey(params.id); + if (!key) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + return NextResponse.json({ key }); +} + +// ─── DELETE /api/v1/registered-keys/[id] ───────────────────────────────────── + +export async function DELETE(request: Request, { params }: { params: { id: string } }) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const revoked = revokeRegisteredKey(params.id); + if (!revoked) { + return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 }); + } + + return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() }); +} diff --git a/src/app/api/v1/registered-keys/route.ts b/src/app/api/v1/registered-keys/route.ts new file mode 100644 index 0000000000..82c4fa5193 --- /dev/null +++ b/src/app/api/v1/registered-keys/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { issueRegisteredKey, checkQuota, listRegisteredKeys } from "@/lib/db/registeredKeys"; + +// ─── Validation ─────────────────────────────────────────────────────────────── + +const issueKeySchema = z.object({ + name: z.string().min(1).max(120), + provider: z.string().max(80).optional().default(""), + accountId: z.string().max(120).optional().default(""), + idempotencyKey: z.string().max(256).optional(), + expiresAt: z.string().datetime({ offset: true }).optional(), + dailyBudget: z.number().int().positive().optional(), + hourlyBudget: z.number().int().positive().optional(), +}); + +// ─── GET /api/v1/registered-keys ───────────────────────────────────────────── + +/** + * List registered keys (masked — no raw key material returned after creation). + * Optional query params: ?provider=&accountId= + */ +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider") ?? undefined; + const accountId = searchParams.get("accountId") ?? undefined; + + try { + const keys = listRegisteredKeys({ provider, accountId }); + return NextResponse.json({ keys, total: keys.length }); + } catch (err) { + console.error("[registered-keys] GET failed:", err); + return NextResponse.json({ error: "Failed to list registered keys" }, { status: 500 }); + } +} + +// ─── POST /api/v1/registered-keys ──────────────────────────────────────────── + +/** + * Issue a new registered key. + * + * Checks provider + account quotas before issuing. + * Returns the raw key ONCE — it is never stored in plain text. + * Subsequent fetches will only return the masked prefix. + */ +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = issueKeySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const { provider, accountId } = parsed.data; + + // ── Quota check ── + try { + const quota = checkQuota(provider, accountId); + if (!quota.allowed) { + return NextResponse.json( + { error: quota.errorMessage, errorCode: quota.errorCode }, + { status: 429 } + ); + } + } catch (err) { + console.error("[registered-keys] quota check failed:", err); + return NextResponse.json({ error: "Quota check failed" }, { status: 500 }); + } + + // ── Issue ── + try { + const result = issueRegisteredKey(parsed.data); + + if ("idempotencyConflict" in result) { + return NextResponse.json( + { + error: "Idempotency key already used", + errorCode: "IDEMPOTENCY_CONFLICT", + existing: result.existing, + }, + { status: 409 } + ); + } + + const { rawKey, ...keyMeta } = result; + return NextResponse.json( + { + key: rawKey, // ← shown ONCE only + keyId: keyMeta.id, + keyPrefix: keyMeta.keyPrefix, + name: keyMeta.name, + provider: keyMeta.provider, + accountId: keyMeta.accountId, + expiresAt: keyMeta.expiresAt, + createdAt: keyMeta.createdAt, + warning: "Store this key securely — it will not be shown again.", + }, + { status: 201 } + ); + } catch (err) { + console.error("[registered-keys] issue failed:", err); + return NextResponse.json({ error: "Failed to issue key" }, { status: 500 }); + } +} diff --git a/src/lib/db/migrations/008_registered_keys.sql b/src/lib/db/migrations/008_registered_keys.sql new file mode 100644 index 0000000000..7cd2b76ded --- /dev/null +++ b/src/lib/db/migrations/008_registered_keys.sql @@ -0,0 +1,65 @@ +-- Migration 008: Registered Keys Provisioning API (#464) +-- +-- Adds three tables: +-- registered_keys — auto-provisioned API keys with quota metadata +-- provider_key_limits — per-provider issuance limits +-- account_key_limits — per-account issuance limits + +-- -------------------------------------------------------------------------- +-- Table: registered_keys +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS registered_keys ( + id TEXT PRIMARY KEY, -- UUID + key TEXT NOT NULL UNIQUE, -- hashed key material (sha256) + key_prefix TEXT NOT NULL, -- first 8 chars for display (e.g. "ork_abc1") + name TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', -- associated provider (optional) + account_id TEXT NOT NULL DEFAULT '', -- account/tenant identifier + is_active INTEGER NOT NULL DEFAULT 1, + revoked_at TEXT, -- ISO timestamp, null if active + expires_at TEXT, -- ISO timestamp, null = no expiry + idempotency_key TEXT UNIQUE, -- prevents duplicate issue requests + daily_budget INTEGER, -- max requests per day (null = unlimited) + hourly_budget INTEGER, -- max requests per hour (null = unlimited) + daily_used INTEGER NOT NULL DEFAULT 0, + hourly_used INTEGER NOT NULL DEFAULT 0, + last_reset_day TEXT NOT NULL DEFAULT '', -- YYYY-MM-DD for daily reset tracking + last_reset_hour TEXT NOT NULL DEFAULT '', -- YYYY-MM-DDTHH for hourly reset tracking + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_registered_keys_provider ON registered_keys(provider); +CREATE INDEX IF NOT EXISTS idx_registered_keys_account ON registered_keys(account_id); +CREATE INDEX IF NOT EXISTS idx_registered_keys_active ON registered_keys(is_active); +CREATE INDEX IF NOT EXISTS idx_registered_keys_idempotency ON registered_keys(idempotency_key); + +-- -------------------------------------------------------------------------- +-- Table: provider_key_limits (per-provider issuance limits) +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS provider_key_limits ( + provider TEXT PRIMARY KEY, + max_active_keys INTEGER, -- null = unlimited + daily_issue_limit INTEGER, -- max keys per day + hourly_issue_limit INTEGER, -- max keys per hour + daily_issued INTEGER NOT NULL DEFAULT 0, + hourly_issued INTEGER NOT NULL DEFAULT 0, + last_reset_day TEXT NOT NULL DEFAULT '', + last_reset_hour TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- -------------------------------------------------------------------------- +-- Table: account_key_limits (per-account issuance limits) +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS account_key_limits ( + account_id TEXT PRIMARY KEY, + max_active_keys INTEGER, + daily_issue_limit INTEGER, + hourly_issue_limit INTEGER, + daily_issued INTEGER NOT NULL DEFAULT 0, + hourly_issued INTEGER NOT NULL DEFAULT 0, + last_reset_day TEXT NOT NULL DEFAULT '', + last_reset_hour TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/lib/db/registeredKeys.ts b/src/lib/db/registeredKeys.ts new file mode 100644 index 0000000000..3e42cb6353 --- /dev/null +++ b/src/lib/db/registeredKeys.ts @@ -0,0 +1,531 @@ +/** + * db/registeredKeys.ts — Registered Keys Provisioning (#464) + * + * Handles: + * - Issuing registered keys with idempotency + * - Per-provider and per-account quota enforcement + * - Key revocation + * - Quota status queries for rate-limiting decisions + */ + +import { createHash, randomBytes } from "crypto"; +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance, rowToCamel } from "./core"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface RegisteredKey { + id: string; + keyPrefix: string; + name: string; + provider: string; + accountId: string; + isActive: boolean; + revokedAt: string | null; + expiresAt: string | null; + idempotencyKey: string | null; + dailyBudget: number | null; + hourlyBudget: number | null; + dailyUsed: number; + hourlyUsed: number; + createdAt: string; + updatedAt: string; +} + +export interface RegisteredKeyWithSecret extends RegisteredKey { + /** Raw key material — only returned once on creation */ + rawKey: string; +} + +export interface ProviderKeyLimit { + provider: string; + maxActiveKeys: number | null; + dailyIssueLimit: number | null; + hourlyIssueLimit: number | null; + dailyIssued: number; + hourlyIssued: number; + updatedAt: string; +} + +export interface AccountKeyLimit { + accountId: string; + maxActiveKeys: number | null; + dailyIssueLimit: number | null; + hourlyIssueLimit: number | null; + dailyIssued: number; + hourlyIssued: number; + updatedAt: string; +} + +export interface QuotaCheckResult { + allowed: boolean; + errorCode?: string; + errorMessage?: string; + provider?: string; + accountId?: string; + providerActiveKeys?: number; + accountActiveKeys?: number; +} + +export interface IssueKeyParams { + name: string; + provider?: string; + accountId?: string; + idempotencyKey?: string; + expiresAt?: string; + dailyBudget?: number; + hourlyBudget?: number; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function nowDay(): string { + return new Date().toISOString().slice(0, 10); // YYYY-MM-DD +} + +function nowHour(): string { + return new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH +} + +function hashKey(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function generateRawKey(): string { + // ork_ prefix so users can easily identify these keys + return "ork_" + randomBytes(24).toString("base64url"); +} + +/** Reset window counters if the tracking period has changed. */ +function maybeResetWindow( + db: ReturnType, + table: string, + idField: string, + idValue: string +): void { + const today = nowDay(); + const hour = nowHour(); + + db.prepare( + ` + UPDATE ${table} + SET daily_issued = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_issued END, + hourly_issued = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_issued END, + last_reset_day = ?, + last_reset_hour = ? + WHERE ${idField} = ? + ` + ).run(today, hour, today, hour, idValue); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Check if a new registered key can be issued for the given provider/account. + * Returns { allowed: true } or { allowed: false, errorCode, errorMessage }. + */ +export function checkQuota(provider = "", accountId = ""): QuotaCheckResult { + const db = getDbInstance(); + const today = nowDay(); + const hour = nowHour(); + + // ── provider-level check ── + if (provider) { + maybeResetWindow(db, "provider_key_limits", "provider", provider); + + const limits = db + .prepare("SELECT * FROM provider_key_limits WHERE provider = ?") + .get(provider) as ProviderKeyLimitRow | undefined; + + if (limits) { + if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) { + return { + allowed: false, + errorCode: "PROVIDER_QUOTA_EXCEEDED", + errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for provider '${provider}'`, + provider, + }; + } + if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) { + return { + allowed: false, + errorCode: "PROVIDER_QUOTA_EXCEEDED", + errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for provider '${provider}'`, + provider, + }; + } + if (limits.max_active_keys !== null) { + const { activeCount } = db + .prepare( + "SELECT COUNT(*) as activeCount FROM registered_keys WHERE provider = ? AND is_active = 1" + ) + .get(provider) as { activeCount: number }; + if (activeCount >= limits.max_active_keys) { + return { + allowed: false, + errorCode: "MAX_ACTIVE_KEYS_EXCEEDED", + errorMessage: `Max active keys (${limits.max_active_keys}) reached for provider '${provider}'`, + provider, + providerActiveKeys: activeCount, + }; + } + } + } + } + + // ── account-level check ── + if (accountId) { + maybeResetWindow(db, "account_key_limits", "account_id", accountId); + + const limits = db + .prepare("SELECT * FROM account_key_limits WHERE account_id = ?") + .get(accountId) as AccountKeyLimitRow | undefined; + + if (limits) { + if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) { + return { + allowed: false, + errorCode: "ACCOUNT_QUOTA_EXCEEDED", + errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for account '${accountId}'`, + accountId, + }; + } + if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) { + return { + allowed: false, + errorCode: "ACCOUNT_QUOTA_EXCEEDED", + errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for account '${accountId}'`, + accountId, + }; + } + if (limits.max_active_keys !== null) { + const { activeCount } = db + .prepare( + "SELECT COUNT(*) as activeCount FROM registered_keys WHERE account_id = ? AND is_active = 1" + ) + .get(accountId) as { activeCount: number }; + if (activeCount >= limits.max_active_keys) { + return { + allowed: false, + errorCode: "MAX_ACTIVE_KEYS_EXCEEDED", + errorMessage: `Max active keys (${limits.max_active_keys}) reached for account '${accountId}'`, + accountId, + accountActiveKeys: activeCount, + }; + } + } + } + } + + return { allowed: true }; +} + +/** + * Issue a new registered key. + * Returns the key with rawKey (only on creation) or null if idempotency_key already exists. + */ +export function issueRegisteredKey( + params: IssueKeyParams +): RegisteredKeyWithSecret | { idempotencyConflict: true; existing: RegisteredKey } { + const db = getDbInstance(); + const { + name, + provider = "", + accountId = "", + idempotencyKey, + expiresAt, + dailyBudget, + hourlyBudget, + } = params; + + // ── idempotency check ── + if (idempotencyKey) { + const existing = db + .prepare("SELECT * FROM registered_keys WHERE idempotency_key = ?") + .get(idempotencyKey) as RegisteredKeyRow | undefined; + if (existing) { + return { + idempotencyConflict: true, + existing: rowToCamel(existing) as unknown as RegisteredKey, + }; + } + } + + const rawKey = generateRawKey(); + const id = uuidv4(); + const keyHash = hashKey(rawKey); + const keyPrefix = rawKey.slice(0, 12); // "ork_" + 8 chars + + db.prepare( + ` + INSERT INTO registered_keys + (id, key, key_prefix, name, provider, account_id, idempotency_key, expires_at, daily_budget, hourly_budget, last_reset_day, last_reset_hour) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + keyHash, + keyPrefix, + name, + provider, + accountId, + idempotencyKey ?? null, + expiresAt ?? null, + dailyBudget ?? null, + hourlyBudget ?? null, + nowDay(), + nowHour() + ); + + // Increment provider/account issuance counters + if (provider) { + maybeResetWindow(db, "provider_key_limits", "provider", provider); + db.prepare( + ` + INSERT INTO provider_key_limits (provider, daily_issued, hourly_issued, last_reset_day, last_reset_hour) + VALUES (?, 1, 1, ?, ?) + ON CONFLICT(provider) DO UPDATE SET + daily_issued = daily_issued + 1, + hourly_issued = hourly_issued + 1, + updated_at = datetime('now') + ` + ).run(provider, nowDay(), nowHour()); + } + if (accountId) { + maybeResetWindow(db, "account_key_limits", "account_id", accountId); + db.prepare( + ` + INSERT INTO account_key_limits (account_id, daily_issued, hourly_issued, last_reset_day, last_reset_hour) + VALUES (?, 1, 1, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + daily_issued = daily_issued + 1, + hourly_issued = hourly_issued + 1, + updated_at = datetime('now') + ` + ).run(accountId, nowDay(), nowHour()); + } + + const created = db + .prepare("SELECT * FROM registered_keys WHERE id = ?") + .get(id) as RegisteredKeyRow; + return { ...(rowToCamel(created) as unknown as RegisteredKey), rawKey }; +} + +/** + * Get a registered key by ID (without the raw key — only prefix is returned). + */ +export function getRegisteredKey(id: string): RegisteredKey | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM registered_keys WHERE id = ?").get(id) as + | RegisteredKeyRow + | undefined; + return row ? (rowToCamel(row) as unknown as RegisteredKey) : null; +} + +/** + * List all registered keys (optionally filtered by provider/accountId). + */ +export function listRegisteredKeys( + opts: { provider?: string; accountId?: string } = {} +): RegisteredKey[] { + const db = getDbInstance(); + let sql = "SELECT * FROM registered_keys WHERE 1=1"; + const args: string[] = []; + if (opts.provider) { + sql += " AND provider = ?"; + args.push(opts.provider); + } + if (opts.accountId) { + sql += " AND account_id = ?"; + args.push(opts.accountId); + } + sql += " ORDER BY created_at DESC LIMIT 500"; + const rows = db.prepare(sql).all(...args) as RegisteredKeyRow[]; + return rows.map((r) => rowToCamel(r) as unknown as RegisteredKey); +} + +/** + * Revoke a registered key by ID. + */ +export function revokeRegisteredKey(id: string): boolean { + const db = getDbInstance(); + const result = db + .prepare( + ` + UPDATE registered_keys + SET is_active = 0, revoked_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND is_active = 1 + ` + ) + .run(id); + return result.changes > 0; +} + +/** + * Validate a raw registered key against stored hashes. + * Returns the key metadata if valid, null otherwise. + */ +export function validateRegisteredKey(rawKey: string): RegisteredKey | null { + const db = getDbInstance(); + const hash = hashKey(rawKey); + const row = db + .prepare( + ` + SELECT * FROM registered_keys + WHERE key = ? AND is_active = 1 + AND (expires_at IS NULL OR expires_at > datetime('now')) + ` + ) + .get(hash) as RegisteredKeyRow | undefined; + if (!row) return null; + + // Auto-reset budget windows if needed + const today = nowDay(); + const hour = nowHour(); + if (row.last_reset_day !== today || row.last_reset_hour !== hour) { + db.prepare( + ` + UPDATE registered_keys + SET daily_used = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_used END, + hourly_used = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_used END, + last_reset_day = ?, last_reset_hour = ? + WHERE id = ? + ` + ).run(today, hour, today, hour, row.id); + } + + // Budget check + if (row.daily_budget !== null && row.daily_used >= row.daily_budget) return null; + if (row.hourly_budget !== null && row.hourly_used >= row.hourly_budget) return null; + + return rowToCamel(row) as unknown as RegisteredKey; +} + +/** + * Increment usage counters for a registered key (called by request pipeline). + */ +export function incrementRegisteredKeyUsage(id: string): void { + const db = getDbInstance(); + db.prepare( + ` + UPDATE registered_keys + SET daily_used = daily_used + 1, hourly_used = hourly_used + 1, updated_at = datetime('now') + WHERE id = ? + ` + ).run(id); +} + +// ─── Provider / Account Limit Management ────────────────────────────────────── + +export function setProviderKeyLimit( + provider: string, + limits: Partial> +): void { + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO provider_key_limits (provider, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(provider) DO UPDATE SET + max_active_keys = excluded.max_active_keys, + daily_issue_limit = excluded.daily_issue_limit, + hourly_issue_limit = excluded.hourly_issue_limit, + updated_at = datetime('now') + ` + ).run( + provider, + limits.maxActiveKeys ?? null, + limits.dailyIssueLimit ?? null, + limits.hourlyIssueLimit ?? null, + nowDay(), + nowHour() + ); +} + +export function setAccountKeyLimit( + accountId: string, + limits: Partial> +): void { + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO account_key_limits (account_id, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + max_active_keys = excluded.max_active_keys, + daily_issue_limit = excluded.daily_issue_limit, + hourly_issue_limit = excluded.hourly_issue_limit, + updated_at = datetime('now') + ` + ).run( + accountId, + limits.maxActiveKeys ?? null, + limits.dailyIssueLimit ?? null, + limits.hourlyIssueLimit ?? null, + nowDay(), + nowHour() + ); +} + +export function getProviderKeyLimit(provider: string): ProviderKeyLimit | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM provider_key_limits WHERE provider = ?").get(provider) as + | ProviderKeyLimitRow + | undefined; + return row ? (rowToCamel(row) as unknown as ProviderKeyLimit) : null; +} + +export function getAccountKeyLimit(accountId: string): AccountKeyLimit | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM account_key_limits WHERE account_id = ?").get(accountId) as + | AccountKeyLimitRow + | undefined; + return row ? (rowToCamel(row) as unknown as AccountKeyLimit) : null; +} + +// ─── Internal types (raw DB rows) ───────────────────────────────────────────── + +interface RegisteredKeyRow { + id: string; + key: string; + key_prefix: string; + name: string; + provider: string; + account_id: string; + is_active: number; + revoked_at: string | null; + expires_at: string | null; + idempotency_key: string | null; + daily_budget: number | null; + hourly_budget: number | null; + daily_used: number; + hourly_used: number; + last_reset_day: string; + last_reset_hour: string; + created_at: string; + updated_at: string; +} + +interface ProviderKeyLimitRow { + provider: string; + max_active_keys: number | null; + daily_issue_limit: number | null; + hourly_issue_limit: number | null; + daily_issued: number; + hourly_issued: number; + last_reset_day: string; + last_reset_hour: string; + updated_at: string; +} + +interface AccountKeyLimitRow { + account_id: string; + max_active_keys: number | null; + daily_issue_limit: number | null; + hourly_issue_limit: number | null; + daily_issued: number; + hourly_issued: number; + last_reset_day: string; + last_reset_hour: string; + updated_at: string; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index c327aed26c..042b5851f0 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -138,3 +138,27 @@ export { getCachedProviderConnections, invalidateDbCache, } from "./db/readCache"; + +export { + // Registered Keys Provisioning (#464) + issueRegisteredKey, + getRegisteredKey, + listRegisteredKeys, + revokeRegisteredKey, + validateRegisteredKey, + incrementRegisteredKeyUsage, + checkQuota, + setProviderKeyLimit, + setAccountKeyLimit, + getProviderKeyLimit, + getAccountKeyLimit, +} from "./db/registeredKeys"; + +export type { + RegisteredKey, + RegisteredKeyWithSecret, + ProviderKeyLimit, + AccountKeyLimit, + QuotaCheckResult, + IssueKeyParams, +} from "./db/registeredKeys";