mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-09-05 02:19:54 +00:00
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:
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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's okay —
|
||||
it's free; a regenerate flow may come later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<button className="btn" onClick={onGenerate} disabled={loading}>
|
||||
|
||||
@@ -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,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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user