feat(3.0.0-rc.5): Registered Keys Provisioning API (#464)

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
This commit is contained in:
diegosouzapw
2026-03-22 15:33:45 -03:00
parent f3c5e55b26
commit 95ffc21b60
14 changed files with 1067 additions and 4 deletions

View File

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

View File

@@ -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,

4
package-lock.json generated
View File

@@ -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": [

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'))
);

View File

@@ -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<typeof getDbInstance>,
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<Omit<ProviderKeyLimit, "provider" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
): 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<Omit<AccountKeyLimit, "accountId" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
): 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;
}

View File

@@ -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";