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.
This commit is contained in:
2026-06-14 14:10:46 +07:00
parent bd25fb1efa
commit 3eda216dc6
10 changed files with 42 additions and 46 deletions
+4 -5
View File
@@ -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)) {
+3 -3
View File
@@ -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() {
<main>
<h1>Your key</h1>
<p className="muted">Signed in as @{identity.githubUsername}.</p>
<GenerateKeyPanel existingHint={existingHint} model={model} repoUrl={repoUrl} />
<GenerateKeyPanel existingKey={existingKey} model={model} repoUrl={repoUrl} />
</main>
);
}
+4 -4
View File
@@ -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 }) {
<tr key={row.id}>
<td>@{row.github_username}</td>
<td>
<code>{maskFromHint(row.key_hint)}</code>
<code>{row.openrouter_key ?? "—"}</code>
</td>
<td>{row.status}</td>
<td className="muted">
+8 -22
View File
@@ -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 <KeyDisplay rawKey={state.rawKey} model={model} />;
}
if (state.status === "exists") {
return (
<div className="panel">
<p>
Your key (masked): <code>{maskFromHint(state.keyHint)}</code>
</p>
<p className="muted">
The full key is shown only once at creation. Lost it? That&apos;s okay
it&apos;s free; a regenerate flow may come later.
</p>
</div>
);
}
return (
<div className="panel">
<button className="btn" onClick={onGenerate} disabled={loading}>
+4 -4
View File
@@ -3,9 +3,8 @@
import { useState } from "react";
/**
* One-time raw key display with a copy button and a prominent "shown once"
* warning. After this render the key is gone (never re-fetchable) — v1 has no
* recovery path, which is acceptable for a free key.
* Raw key display with a copy button. The key is stored and retrievable on the
* dashboard, so this renders both at creation and on return visits.
*
* @param {{ rawKey: string, model: string }} props
*/
@@ -24,7 +23,8 @@ export function KeyDisplay({ rawKey, model }) {
return (
<div className="panel">
<p className="warn">
Copy this key now it is shown only once and cannot be recovered.
Keep this key secret treat it like a password. You can always copy it
again here on your dashboard.
</p>
<div className="key-box">
<code>{rawKey}</code>
+7 -5
View File
@@ -7,7 +7,8 @@ import { getSql } from "@/lib/db/postgres-client";
* @property {string} id
* @property {string} github_user_id
* @property {string} github_username
* @property {string|null} openrouter_key_hash
* @property {string|null} openrouter_key_hash OpenRouter delete handle (for revoke), NOT a hash of the key.
* @property {string|null} openrouter_key Raw key, stored so users can retrieve it later.
* @property {string|null} key_hint
* @property {string} status 'pending' | 'active'
* @property {string} created_at
@@ -33,17 +34,18 @@ export async function reserve(githubUserId, githubUsername) {
}
/**
* Mark a reserved row active with its mint result.
* Mark a reserved row active with its mint result. Stores the raw key so it can
* be retrieved later, plus the delete handle (`hash`) and last-4 hint.
*
* @param {string} id
* @param {{ hash: string, hint: string }} mint
* @param {{ hash: string, hint: string, rawKey: string }} mint
* @returns {Promise<void>}
*/
export async function activate(id, { hash, hint }) {
export async function activate(id, { hash, hint, rawKey }) {
const sql = getSql();
await sql`
update llmapikey.api_keys
set openrouter_key_hash = ${hash}, key_hint = ${hint}, status = 'active'
set openrouter_key_hash = ${hash}, openrouter_key = ${rawKey}, key_hint = ${hint}, status = 'active'
where id = ${id}`;
}
+1 -2
View File
@@ -4,8 +4,7 @@
*/
/**
* Last 4 chars of a raw key, for masked display/storage. The raw key itself is
* never stored; only this hint is.
* Last 4 chars of a raw key, for masked display (e.g. the admin create toast).
*
* @param {string} rawKey
* @returns {string}
+1 -1
View File
@@ -42,7 +42,7 @@ export async function mintAndPersist(reservedId, githubUserId) {
}
try {
await repo.activate(reservedId, { hash: mint.hash, hint: last4(mint.key) });
await repo.activate(reservedId, { hash: mint.hash, hint: last4(mint.key), rawKey: mint.key });
} catch {
try {
await deleteKey(mint.hash); // avoid an orphaned billable key
@@ -0,0 +1,4 @@
-- Rollback for 0002. Drops the stored raw key (revocation still works via
-- openrouter_key_hash). Run manually against the same DB; Vercel rollback does
-- not revert the database.
alter table llmapikey.api_keys drop column if exists openrouter_key;
@@ -0,0 +1,6 @@
-- Store the raw OpenRouter key so users can retrieve it later (no more
-- shown-once-only). The key still lives only in the unexposed `llmapikey`
-- schema (deny-all RLS, reached solely by the server-side direct connection).
-- `openrouter_key_hash` is retained — it is OpenRouter's delete handle for
-- revocation, not a hash of the key.
alter table llmapikey.api_keys add column if not exists openrouter_key text;