From 3eda216dc6346fb92492ab55ce75796c82ae8e5a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 14 Jun 2026 14:10:46 +0700 Subject: [PATCH] feat(keys): store raw OpenRouter key and make it retrievable Persist the raw key (migration 0002 adds openrouter_key) so users can copy it again from the dashboard instead of a one-time-only reveal; admin console shows the full key. Retain openrouter_key_hash for revocation. Keys remain in the unexposed llmapikey schema (deny-all RLS, server-only direct connection). - activate() stores the raw key; dashboard + generate-key return the full key. - key-display warning updated (no longer shown-once). - admin table renders the full key. --- app/actions/generate-key.js | 9 +++--- app/dashboard/page.js | 6 ++-- components/admin/admin-keys-table.js | 8 ++--- components/generate-key-panel.js | 30 +++++-------------- components/key-display.js | 8 ++--- lib/keys/api-keys-repository.js | 12 ++++---- lib/keys/key-format.js | 3 +- lib/keys/mint-key.js | 2 +- .../0002_api_keys_store_raw_key.down.sql | 4 +++ .../0002_api_keys_store_raw_key.up.sql | 6 ++++ 10 files changed, 42 insertions(+), 46 deletions(-) create mode 100644 supabase/migrations/0002_api_keys_store_raw_key.down.sql create mode 100644 supabase/migrations/0002_api_keys_store_raw_key.up.sql diff --git a/app/actions/generate-key.js b/app/actions/generate-key.js index 08d6376..27f2ebd 100644 --- a/app/actions/generate-key.js +++ b/app/actions/generate-key.js @@ -9,8 +9,7 @@ import { mintAndPersist, numEnv } from "@/lib/keys/mint-key"; /** * @typedef {Object} GenerateKeyResult * @property {"created"|"exists"|"error"} status - * @property {string} [rawKey] present only when status === "created" (shown once) - * @property {string|null} [keyHint] last-4 hint for masked display + * @property {string|null} [rawKey] full key, present for "created" and "exists" (retrievable) * @property {string} [message] human-friendly info/error */ @@ -35,10 +34,10 @@ export async function generateKey() { return { status: "error", message: "Sign in with GitHub first." }; } - // Idempotency fast-path: existing active key → masked hint, never mint again. + // Idempotency fast-path: existing active key → return it, never mint again. const existing = await repo.findByGithubUserId(identity.githubUserId); if (existing && existing.status === "active") { - return { status: "exists", keyHint: existing.key_hint, message: "You already have a key." }; + return { status: "exists", rawKey: existing.openrouter_key, message: "You already have a key." }; } // Feature gate: live minting stays OFF until the OpenRouter ToS gate clears. @@ -87,7 +86,7 @@ export async function generateKey() { async function resolveConflict(identity) { const row = await repo.findByGithubUserId(identity.githubUserId); if (row?.status === "active") { - return { result: { status: "exists", keyHint: row.key_hint, message: "You already have a key." } }; + return { result: { status: "exists", rawKey: row.openrouter_key, message: "You already have a key." } }; } // Pending row. If stale, an earlier mint was interrupted — reclaim and retry. if (row && isStale(row.created_at, STALE_PENDING_MS)) { diff --git a/app/dashboard/page.js b/app/dashboard/page.js index 6c08083..ee2bcf0 100644 --- a/app/dashboard/page.js +++ b/app/dashboard/page.js @@ -25,10 +25,10 @@ export default async function DashboardPage() { ); } - let existingHint = null; + let existingKey = null; try { const row = await repo.findByGithubUserId(identity.githubUserId); - if (row && row.status === "active") existingHint = row.key_hint; + if (row && row.status === "active") existingKey = row.openrouter_key; } catch { // DB not reachable (e.g. local without POSTGRES_URL) — show the panel; the // server action gates minting and reports a friendly error. @@ -41,7 +41,7 @@ export default async function DashboardPage() {

Your key

Signed in as @{identity.githubUsername}.

- +
); } diff --git a/components/admin/admin-keys-table.js b/components/admin/admin-keys-table.js index 86654c9..4bdec8b 100644 --- a/components/admin/admin-keys-table.js +++ b/components/admin/admin-keys-table.js @@ -1,9 +1,9 @@ -import { maskFromHint } from "@/lib/keys/key-format"; import { AdminKeyRowActions } from "./admin-key-row-actions"; /** - * Renders only safe columns: username, masked key hint, status, created date. - * The `openrouter_key_hash` is NEVER rendered or serialized to the client. + * Admin table: username, full key, status, created date. The full raw key is + * shown here intentionally (admin-only, gated route). The delete handle + * (`openrouter_key_hash`) is never rendered. * * @param {{ rows: import('@/lib/keys/api-keys-repository').ApiKeyRow[] }} props */ @@ -33,7 +33,7 @@ export function AdminKeysTable({ rows }) { @{row.github_username} - {maskFromHint(row.key_hint)} + {row.openrouter_key ?? "—"} {row.status} diff --git a/components/generate-key-panel.js b/components/generate-key-panel.js index 7675b67..5f02c3b 100644 --- a/components/generate-key-panel.js +++ b/components/generate-key-panel.js @@ -3,19 +3,18 @@ import { useState } from "react"; import { generateKey } from "@/app/actions/generate-key"; -import { maskFromHint } from "@/lib/keys/key-format"; import { KeyDisplay } from "./key-display"; /** - * Generate / existing-key panel. Calls the server action; renders the one-time - * key on success, the masked hint if a key already exists. + * Generate / existing-key panel. Calls the server action; renders the full key + * (retrievable) whether it was just created or already existed. * - * @param {{ existingHint: string|null, model: string, repoUrl: string }} props + * @param {{ existingKey: string|null, model: string, repoUrl: string }} props */ -export function GenerateKeyPanel({ existingHint, model, repoUrl }) { +export function GenerateKeyPanel({ existingKey, model, repoUrl }) { const [state, setState] = useState( - existingHint - ? { status: "exists", keyHint: existingHint } + existingKey + ? { status: "exists", rawKey: existingKey } : { status: "idle" }, ); const [loading, setLoading] = useState(false); @@ -27,24 +26,11 @@ export function GenerateKeyPanel({ existingHint, model, repoUrl }) { setLoading(false); } - if (state.status === "created") { + // Both freshly created and previously existing keys render the full value. + if (state.status === "created" || state.status === "exists") { return ; } - if (state.status === "exists") { - return ( -
-

- Your key (masked): {maskFromHint(state.keyHint)} -

-

- The full key is shown only once at creation. Lost it? That's okay — - it's free; a regenerate flow may come later. -

-
- ); - } - return (