feat: scaffold OpenRouter key giveaway site (gated, code-only)

- Next.js 15 App Router (JS+JSDoc): landing, auth-gated dashboard, docs
- GitHub OAuth via Supabase; identity anchored on numeric provider_id
- key provisioning: reserve-then-mint-persist-compensate, one key per account
- api_keys in unexposed llmapikey schema via direct Postgres; RLS deny-all
- live minting gated behind PROVISIONING_ENABLED; Vercel auto-deploy disabled
- unit tests (mask, request-body), RLS deny-all test, reconcile script
This commit is contained in:
2026-06-13 14:18:52 +07:00
parent 577b089780
commit 02fa52ccf9
41 changed files with 7207 additions and 12 deletions
+32
View File
@@ -0,0 +1,32 @@
# ---- Supabase (auth) ----
# Public: safe to expose to the browser. Used for GitHub OAuth sign-in only.
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# ---- Postgres (direct, server-only) ----
# Supabase pooler connection string (Project Settings > Database > Connection
# string > "Transaction" pooler, port 6543). Reaches the unexposed `llmapikey`
# schema. NEVER expose to the client. The connecting role must own/bypass RLS on
# the llmapikey schema (project `postgres` user).
DATABASE_URL=postgresql://postgres.your-ref:password@aws-0-region.pooler.supabase.com:6543/postgres
# ---- OpenRouter (server-only secret) ----
# Master Provisioning key used to mint per-user keys. NEVER expose to the client.
OPENROUTER_PROVISIONING_KEY=sk-or-v1-provisioning-...
# ---- App config ----
# Public repo URL for the soft star nudge.
NEXT_PUBLIC_REPO_URL=https://github.com/your-org/llmapikey
# Model id shown in /docs and used as the documented target.
NEXT_PUBLIC_OPENROUTER_MODEL=minimax/minimax-m3
# ---- Provisioning controls (server-only) ----
# Feature flag: live key minting is OFF until OpenRouter ToS gate (Phase 1)
# clears. When "false", generateKey() refuses to mint and returns a gated error.
PROVISIONING_ENABLED=false
# Sybil/abuse kill-switch: stop minting once this many active keys exist.
MAX_TOTAL_KEYS=500
# Per-key daily spend cap (USD) sent to OpenRouter.
KEY_DAILY_LIMIT_USD=10
# Key lifetime in days (sets expires_at on mint).
KEY_EXPIRY_DAYS=90
+8
View File
@@ -1,6 +1,7 @@
# Environment / secrets
.env
.env.*
!.env.example
*.key
*.pem
@@ -11,6 +12,13 @@ __pycache__/
.venv/
venv/
# Next.js
.next/
out/
next-env.d.ts
*.tsbuildinfo
.vercel
# OS / editor
.DS_Store
.idea/
+70 -1
View File
@@ -1,3 +1,72 @@
# llmapikey
LLM API key management.
Free, capped OpenRouter API key giveaway — one key per GitHub account.
Next.js (App Router, JS + JSDoc) on Vercel. GitHub sign-in via Supabase Auth.
Per-user OpenRouter keys are minted from the owner's master Provisioning key,
each capped at a daily USD limit. Key records live in a dedicated, unexposed
`llmapikey` Postgres schema reached only by a server-side direct connection.
> **Status:** code build only. Live key minting is gated behind
> `PROVISIONING_ENABLED=false` until the OpenRouter ToS approval gate (plan
> Phase 1) clears. Do not deploy a public giveaway before that.
## Stack
- **Next.js 15** App Router, plain JS + JSDoc (no TypeScript)
- **Supabase Auth** — GitHub OAuth provider (sessions via `@supabase/ssr`)
- **Postgres** (`postgres` npm) — direct connection to the unexposed `llmapikey`
schema; the anon role can never reach `api_keys`
- **OpenRouter Provisioning API** — mints per-user keys
## Architecture notes
- **Identity anchor:** the numeric, immutable GitHub `provider_id` (not the
mutable login). One key per `github_user_id`, enforced by a DB unique constraint.
- **Server-only secrets:** `DATABASE_URL` and `OPENROUTER_PROVISIONING_KEY` are
guarded by the `server-only` package and never reach the client bundle.
- **Reserve-then-mint:** a `pending` row is inserted (ON CONFLICT DO NOTHING)
before minting, so concurrent double-submits yield exactly one OpenRouter key.
- **Schema isolation:** `llmapikey` is NOT added to PostgREST exposed schemas;
RLS is deny-all as defense in depth.
## Setup
1. **Install**
```bash
npm install
```
2. **Environment** — copy `.env.example` to `.env.local` and fill in values.
| Var | Purpose |
|-----|---------|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (public) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anon key (public, auth UI only) |
| `DATABASE_URL` | Supabase **transaction pooler** string (server-only) |
| `OPENROUTER_PROVISIONING_KEY` | Master Provisioning key (server-only) |
| `NEXT_PUBLIC_REPO_URL` | Repo URL for the soft star nudge |
| `NEXT_PUBLIC_OPENROUTER_MODEL` | Model id shown in `/docs` |
| `PROVISIONING_ENABLED` | `false` until Phase 1 ToS gate clears |
| `MAX_TOTAL_KEYS` | Kill-switch: stop minting past N active keys |
| `KEY_DAILY_LIMIT_USD` | Per-key daily cap sent to OpenRouter |
| `KEY_EXPIRY_DAYS` | Key lifetime (sets `expires_at`) |
3. **Database** — apply the migration to a **staging branch first**, then prod
(Supabase SQL editor or `psql "$DATABASE_URL" -f ...`):
```bash
psql "$DATABASE_URL" -f supabase/migrations/0001_llmapikey_schema_and_api_keys.up.sql
```
Do NOT add `llmapikey` to the project's PostgREST "Exposed schemas".
Rollback: `...0001_...down.sql`.
4. **GitHub OAuth** — register a GitHub OAuth App; set the client id/secret in
the Supabase dashboard (Auth > Providers > GitHub), callback
`https://<project>.supabase.co/auth/v1/callback`. Add localhost + your Vercel
domain to Supabase Auth redirect URLs.
5. **Run**
```bash
npm run dev # http://localhost:3000
npm test # unit tests
```
## Deploy (gated)
Auto-build on Vercel is intentionally disabled (see `vercel.json`). Promote
manually once the Phase 1 ToS gate clears and `PROVISIONING_ENABLED=true`.
+166
View File
@@ -0,0 +1,166 @@
"use server";
import "server-only";
import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity";
import { last4 } from "@/lib/keys/key-format";
import * as repo from "@/lib/keys/api-keys-repository";
import { createKey, deleteKey } from "@/lib/openrouter/provisioning-client";
/**
* @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} [message] human-friendly info/error
*/
// A pending row older than this is treated as an interrupted mint and reclaimed,
// so a crashed/timed-out request can never permanently lock a user out.
const STALE_PENDING_MS = 2 * 60 * 1000;
/**
* Generate exactly one OpenRouter key for the signed-in GitHub user.
* Flow: idempotency check → gate/limits → reserve → mint → persist → compensate.
*
* The DB unique constraint (not application locking) is the concurrency guard:
* concurrent double-submits both call reserve(), only one gets a row id.
*
* @returns {Promise<GenerateKeyResult>}
*/
export async function generateKey() {
const identity = await getCurrentGithubIdentity();
if (!identity) {
return { status: "error", message: "Sign in with GitHub first." };
}
// Idempotency fast-path: existing active key → masked hint, 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." };
}
// Feature gate: live minting stays OFF until the OpenRouter ToS gate clears.
if (process.env.PROVISIONING_ENABLED !== "true") {
return { status: "error", message: "Key giveaway is not live yet. Check back soon." };
}
// Sybil kill-switch (cheap early-out; re-checked authoritatively post-reserve).
const maxKeys = numEnv("MAX_TOTAL_KEYS", 0);
if (maxKeys > 0 && (await repo.countLiveKeys()) >= maxKeys) {
return { status: "error", message: "We've reached the current key limit. Please try again later." };
}
// Reserve. A conflicting concurrent submit gets null.
let reservedId = await repo.reserve(identity.githubUserId, identity.githubUsername);
if (!reservedId) {
const resolved = await resolveConflict(identity);
if (resolved.reclaimedId) {
reservedId = resolved.reclaimedId; // stale pending reclaimed — proceed to mint
} else {
return resolved.result;
}
}
// Authoritative ceiling re-check: closes the concurrent-overshoot gap (our own
// pending row is now counted). Free the reservation if we tipped over.
if (maxKeys > 0 && (await repo.countLiveKeys()) > maxKeys) {
await repo.deletePending(reservedId);
return { status: "error", message: "We've reached the current key limit. Please try again later." };
}
return mintAndPersist(reservedId, identity.githubUserId);
}
/**
* Resolve a reserve() conflict: existing active key, reclaimable stale pending,
* or an in-progress request.
*
* @param {{ githubUserId: string, githubUsername: string }} identity
* @returns {Promise<{ reclaimedId?: string, result: GenerateKeyResult }>}
*/
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." } };
}
// Pending row. If stale, an earlier mint was interrupted — reclaim and retry.
if (row && isStale(row.created_at, STALE_PENDING_MS)) {
await repo.deletePending(row.id);
const retryId = await repo.reserve(identity.githubUserId, identity.githubUsername);
if (retryId) return { reclaimedId: retryId, result: { status: "error" } };
}
return {
result: { status: "error", message: "A key request is already in progress. Try again shortly." },
};
}
/**
* Mint then persist, compensating on either failure.
*
* @param {string} reservedId
* @param {string} githubUserId
* @returns {Promise<GenerateKeyResult>}
*/
async function mintAndPersist(reservedId, githubUserId) {
let mint;
try {
mint = await createKey({
name: `llmapikey:${githubUserId}`, // opaque numeric id — no PII into OpenRouter logs
limitUsd: numEnv("KEY_DAILY_LIMIT_USD", 10),
resetPeriod: "daily",
includeByok: true,
expiresAt: expiryIso(numEnv("KEY_EXPIRY_DAYS", 90)),
});
} catch {
await repo.deletePending(reservedId); // free the reservation; no orphan key exists
return { status: "error", message: "Could not create your key. Please try again." };
}
try {
await repo.activate(reservedId, { hash: mint.hash, hint: last4(mint.key) });
} catch {
try {
await deleteKey(mint.hash); // avoid an orphaned billable key
} catch {
// best-effort; reconcile-keys.js will surface any leak
}
await repo.deletePending(reservedId);
return { status: "error", message: "Could not save your key. Please try again." };
}
// Raw key returned for one-time display. Never logged or re-persisted.
return { status: "created", rawKey: mint.key, keyHint: last4(mint.key) };
}
/**
* Parse a numeric env var, falling back if missing or malformed.
*
* @param {string} name
* @param {number} fallback
* @returns {number}
*/
function numEnv(name, fallback) {
const n = Number(process.env[name]);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
/**
* @param {string|Date} createdAt
* @param {number} maxAgeMs
* @returns {boolean}
*/
function isStale(createdAt, maxAgeMs) {
const t = new Date(createdAt).getTime();
return Number.isFinite(t) && Date.now() - t > maxAgeMs;
}
/**
* ISO timestamp `days` in the future for the key's expires_at.
*
* @param {number} days
* @returns {string}
*/
function expiryIso(days) {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { createServerAuthClient } from "@/lib/supabase/server-client";
/**
* OAuth callback: exchange the GitHub auth code for a Supabase session cookie,
* then redirect to `next` (default /dashboard).
*
* @param {Request} request
*/
export async function GET(request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = sanitizeNext(searchParams.get("next"));
if (code) {
const supabase = await createServerAuthClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
/**
* Only allow same-origin relative paths to avoid open-redirect.
*
* @param {string | null} next
* @returns {string}
*/
function sanitizeNext(next) {
if (next && next.startsWith("/") && !next.startsWith("//")) return next;
return "/dashboard";
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { createServerAuthClient } from "@/lib/supabase/server-client";
/**
* Sign out: clear the Supabase session and redirect home.
*
* @param {Request} request
*/
export async function POST(request) {
const supabase = await createServerAuthClient();
await supabase.auth.signOut();
return NextResponse.redirect(new URL("/", request.url), { status: 303 });
}
+47
View File
@@ -0,0 +1,47 @@
import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity";
import { GenerateKeyPanel } from "@/components/generate-key-panel";
import { SignInWithGithubButton } from "@/components/sign-in-with-github-button";
import * as repo from "@/lib/keys/api-keys-repository";
// Reads the session per request — never prerender.
export const dynamic = "force-dynamic";
/**
* Auth-gated dashboard. Unauthenticated → sign-in prompt. Authenticated →
* generate / existing-key panel.
*/
export default async function DashboardPage() {
const identity = await getCurrentGithubIdentity();
if (!identity) {
return (
<main>
<h1>Dashboard</h1>
<div className="panel">
<p>Sign in with GitHub to get your free key.</p>
<SignInWithGithubButton next="/dashboard" />
</div>
</main>
);
}
let existingHint = null;
try {
const row = await repo.findByGithubUserId(identity.githubUserId);
if (row && row.status === "active") existingHint = row.key_hint;
} catch {
// DB not reachable (e.g. local without DATABASE_URL) — show the panel; the
// server action gates minting and reports a friendly error.
}
const model = process.env.NEXT_PUBLIC_OPENROUTER_MODEL ?? "minimax/minimax-m3";
const repoUrl = process.env.NEXT_PUBLIC_REPO_URL ?? "#";
return (
<main>
<h1>Your key</h1>
<p className="muted">Signed in as @{identity.githubUsername}.</p>
<GenerateKeyPanel existingHint={existingHint} model={model} repoUrl={repoUrl} />
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
export const metadata = { title: "Docs — llmapikey" };
/**
* Static usage guide: how to call OpenRouter with the issued key.
*/
export default function DocsPage() {
const model = process.env.NEXT_PUBLIC_OPENROUTER_MODEL ?? "minimax/minimax-m3";
const curlExample = `curl https://openrouter.ai/api/v1/chat/completions \\
-H "Authorization: Bearer $OPENROUTER_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"model": "${model}",
"messages": [{ "role": "user", "content": "Hello!" }]
}'`;
const jsExample = `const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: \`Bearer \${process.env.OPENROUTER_KEY}\`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "${model}",
messages: [{ role: "user", content: "Hello!" }],
}),
});
const data = await res.json();
console.log(data.choices[0].message.content);`;
return (
<main>
<h1>Using your key</h1>
<p>
Your key is a standard OpenRouter API key. Base URL:{" "}
<code>https://openrouter.ai/api/v1</code>. Authenticate with{" "}
<code>Authorization: Bearer &lt;your-key&gt;</code>.
</p>
<h2>Model</h2>
<p>
<code>{model}</code>
</p>
<h2>curl</h2>
<pre>{curlExample}</pre>
<h2>JavaScript (fetch)</h2>
<pre>{jsExample}</pre>
<h2>Limits</h2>
<ul>
<li>Capped at $10/day, resetting daily.</li>
<li>The key is shown only once at creation store it somewhere safe.</li>
<li>One key per GitHub account.</li>
</ul>
</main>
);
}
+140
View File
@@ -0,0 +1,140 @@
:root {
--bg: #0b0d12;
--panel: #141821;
--border: #232a36;
--text: #e6e9ef;
--muted: #9aa4b2;
--accent: #6ea8fe;
--accent-strong: #3b82f6;
--warn: #f0b429;
--danger: #f87171;
--radius: 10px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.55;
}
a {
color: var(--accent);
}
main {
max-width: 880px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
}
header.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 880px;
margin: 0 auto;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
text-decoration: none;
color: var(--text);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin: 1rem 0;
}
.btn {
display: inline-block;
background: var(--accent-strong);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.6rem 1.1rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.muted {
color: var(--muted);
}
.warn {
color: var(--warn);
font-weight: 600;
}
.danger {
color: var(--danger);
}
code,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
pre {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
overflow-x: auto;
font-size: 0.85rem;
}
.key-box {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.key-box code {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
word-break: break-all;
flex: 1;
min-width: 220px;
}
.steps {
display: grid;
gap: 0.75rem;
padding-left: 1.1rem;
}
.error {
color: var(--danger);
font-weight: 600;
}
+25
View File
@@ -0,0 +1,25 @@
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
export const metadata = {
title: "llmapikey — free OpenRouter API key",
description:
"Get a free, capped OpenRouter API key — one per GitHub account. No signup beyond GitHub.",
};
/**
* Root layout. The session-aware header is added in Phase 3.
*
* @param {{ children: React.ReactNode }} props
*/
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SiteHeader />
{children}
</body>
</html>
);
}
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import { SignInWithGithubButton } from "@/components/sign-in-with-github-button";
/**
* Landing page. Static copy + sign-in CTA. Star nudge and how-it-works are
* inline (not separate components) per the "inline static copy" rule.
*/
export default function HomePage() {
const repoUrl = process.env.NEXT_PUBLIC_REPO_URL ?? "#";
const model = process.env.NEXT_PUBLIC_OPENROUTER_MODEL ?? "minimax/minimax-m3";
return (
<main>
<h1>Free OpenRouter API key</h1>
<p>
One free, daily-capped OpenRouter key per GitHub account. Sign in,
generate, start building. No credit card.
</p>
<div className="panel">
<SignInWithGithubButton next="/dashboard" label="Get my free key with GitHub" />
</div>
<h2>Available models</h2>
<ul>
<li>
<code>{model}</code> MiniMax M3
</li>
<li className="muted">More coming soon.</li>
</ul>
<h2>How it works</h2>
<ol className="steps">
<li>Sign in with GitHub we read only your public profile.</li>
<li>Generate your key it&apos;s shown once, so copy it immediately.</li>
<li>Call OpenRouter with your key. Capped at $10/day; resets daily.</li>
</ol>
<p className="muted">
<a href={repoUrl}>Star the repo</a> if it helps you totally optional,
never required for access.
</p>
<p>
<Link href="/docs">Read the usage docs </Link>
</p>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
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.
*
* @param {{ existingHint: string|null, model: string, repoUrl: string }} props
*/
export function GenerateKeyPanel({ existingHint, model, repoUrl }) {
const [state, setState] = useState(
existingHint
? { status: "exists", keyHint: existingHint }
: { status: "idle" },
);
const [loading, setLoading] = useState(false);
async function onGenerate() {
setLoading(true);
const result = await generateKey();
setState(result);
setLoading(false);
}
if (state.status === "created") {
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}>
{loading ? "Generating…" : "Generate my key"}
</button>
{state.status === "error" && <p className="error">{state.message}</p>}
<p className="muted">
<a href={repoUrl}>Star the repo</a> if it helps you optional, never
required.
</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
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.
*
* @param {{ rawKey: string, model: string }} props
*/
export function KeyDisplay({ rawKey, model }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(rawKey);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<div className="panel">
<p className="warn">
Copy this key now it is shown only once and cannot be recovered.
</p>
<div className="key-box">
<code>{rawKey}</code>
<button className="btn" onClick={copy}>
{copied ? "Copied ✓" : "Copy"}
</button>
</div>
<p className="muted">
Use it as a Bearer token against <code>https://openrouter.ai/api/v1</code>{" "}
with model <code>{model}</code>. See <a href="/docs">the docs</a>.
</p>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { createBrowserSupabaseClient } from "@/lib/supabase/browser-client";
/**
* GitHub sign-in button. Kicks off Supabase OAuth; minimal scope (read:user).
*
* @param {{ next?: string, label?: string }} props
*/
export function SignInWithGithubButton({ next = "/dashboard", label = "Sign in with GitHub" }) {
const [loading, setLoading] = useState(false);
async function handleSignIn() {
setLoading(true);
const supabase = createBrowserSupabaseClient();
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: "github",
options: { redirectTo, scopes: "read:user" },
});
if (error) setLoading(false); // on success the browser navigates away
}
return (
<button className="btn" onClick={handleSignIn} disabled={loading}>
{loading ? "Redirecting…" : label}
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Link from "next/link";
import { createServerAuthClient } from "@/lib/supabase/server-client";
/**
* Session-aware header (server component). Shows Dashboard + sign-out when
* authenticated; just Docs otherwise. Display only — `user_name` is fine here.
*/
export async function SiteHeader() {
let username = null;
try {
const supabase = await createServerAuthClient();
const {
data: { user },
} = await supabase.auth.getUser();
username = user?.user_metadata?.user_name ?? null;
} catch {
username = null; // auth not configured (no env) — render signed-out header
}
return (
<header className="site-header">
<Link href="/" className="brand">
llmapikey
</Link>
<nav style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<Link href="/docs">Docs</Link>
{username ? (
<>
<Link href="/dashboard">Dashboard</Link>
<span className="muted">@{username}</span>
<form action="/auth/sign-out" method="post">
<button className="btn secondary" type="submit">
Sign out
</button>
</form>
</>
) : null}
</nav>
</header>
);
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"checkJs": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022"
},
"include": ["**/*.js", "**/*.jsx"],
"exclude": ["node_modules"]
}
+44
View File
@@ -0,0 +1,44 @@
import "server-only";
import { createServerAuthClient } from "@/lib/supabase/server-client";
/**
* @typedef {Object} GithubIdentity
* @property {string} githubUserId Numeric, immutable GitHub id (provider_id).
* @property {string} githubUsername GitHub login (mutable — display only).
*/
/**
* Resolve the current GitHub identity from the validated server session.
*
* Identity anchor: `provider_id` — the numeric, immutable GitHub id. NOT
* `user_name` (the mutable login — a rename could otherwise mint a second key)
* and NOT `sub` (the Supabase user UUID). `user_metadata` is end-user-mutable,
* so it scopes queries server-side only and is never used as an RLS/auth claim.
*
* Uses `getUser()` (validates the token with Supabase), not `getSession()`.
*
* @returns {Promise<GithubIdentity | null>} null when unauthenticated.
*/
export async function getCurrentGithubIdentity() {
const supabase = await createServerAuthClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const meta = user.user_metadata ?? {};
if (meta.provider_id == null || meta.user_name == null) {
throw new Error(
"GitHub identity missing provider_id/user_name in user_metadata",
);
}
const githubUserId = String(meta.provider_id);
// Assert numeric — guards against accidentally anchoring on the mutable login.
if (!/^\d+$/.test(githubUserId)) {
throw new Error(`Expected numeric GitHub provider_id, got: ${githubUserId}`);
}
return { githubUserId, githubUsername: String(meta.user_name) };
}
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import postgres from "postgres";
/**
* Direct Postgres client for the unexposed `llmapikey` schema.
*
* Why direct PG (not supabase-js): the `llmapikey` schema is intentionally NOT
* added to PostgREST's exposed schemas, so the REST API (and therefore the anon
* role) cannot reach `api_keys` at all. supabase-js cannot query an unexposed
* schema. A direct Postgres connection sidesteps PostgREST entirely.
*
* This module is `server-only`; DATABASE_URL must never reach the client bundle.
*
* Connection: use Supabase's pooler connection string. In transaction-pooling
* mode (port 6543) prepared statements are unsupported, so `prepare: false`.
*
* @type {import('postgres').Sql}
*/
let sql;
/**
* Lazily create the singleton sql client. Lazy so importing this module never
* throws at build time when DATABASE_URL is absent (e.g. CI without secrets).
*
* @returns {import('postgres').Sql}
*/
export function getSql() {
if (sql) return sql;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("Missing required environment variable: DATABASE_URL");
}
sql = postgres(connectionString, {
prepare: false,
max: 5,
idle_timeout: 20,
connect_timeout: 10,
});
return sql;
}
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { getSql } from "@/lib/db/postgres-client";
/**
* @typedef {Object} ApiKeyRow
* @property {string} id
* @property {string} github_user_id
* @property {string} github_username
* @property {string|null} openrouter_key_hash
* @property {string|null} key_hint
* @property {string} status 'pending' | 'active'
* @property {string} created_at
*/
/**
* Reserve a pending row. The unique constraint on github_user_id makes this the
* concurrency guard: only the winning insert returns an id; a conflicting
* concurrent submit gets null (ON CONFLICT DO NOTHING → 0 rows) and must NOT mint.
*
* @param {string} githubUserId
* @param {string} githubUsername
* @returns {Promise<string|null>} reserved row id, or null if a row already exists.
*/
export async function reserve(githubUserId, githubUsername) {
const sql = getSql();
const rows = await sql`
insert into llmapikey.api_keys (github_user_id, github_username, status)
values (${githubUserId}, ${githubUsername}, 'pending')
on conflict (github_user_id) do nothing
returning id`;
return rows.length ? rows[0].id : null;
}
/**
* Mark a reserved row active with its mint result.
*
* @param {string} id
* @param {{ hash: string, hint: string }} mint
* @returns {Promise<void>}
*/
export async function activate(id, { hash, hint }) {
const sql = getSql();
await sql`
update llmapikey.api_keys
set openrouter_key_hash = ${hash}, key_hint = ${hint}, status = 'active'
where id = ${id}`;
}
/**
* Delete a pending reservation (compensation when mint or persist fails).
*
* @param {string} id
* @returns {Promise<void>}
*/
export async function deletePending(id) {
const sql = getSql();
await sql`delete from llmapikey.api_keys where id = ${id} and status = 'pending'`;
}
/**
* @param {string} githubUserId
* @returns {Promise<ApiKeyRow|null>}
*/
export async function findByGithubUserId(githubUserId) {
const sql = getSql();
const rows = await sql`
select * from llmapikey.api_keys where github_user_id = ${githubUserId} limit 1`;
return rows.length ? rows[0] : null;
}
/**
* Count live keys (active + in-flight pending) — basis for the MAX_TOTAL_KEYS
* Sybil kill-switch. Pending rows are counted so concurrent reservations cannot
* collectively overshoot the ceiling before any of them activates.
*
* @returns {Promise<number>}
*/
export async function countLiveKeys() {
const sql = getSql();
const rows = await sql`
select count(*)::int as n from llmapikey.api_keys
where status in ('pending', 'active')`;
return rows[0].n;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure key formatting helpers. No secrets, no I/O — safe to import anywhere and
* unit-testable in isolation.
*/
/**
* Last 4 chars of a raw key, for masked display/storage. The raw key itself is
* never stored; only this hint is.
*
* @param {string} rawKey
* @returns {string}
*/
export function last4(rawKey) {
if (typeof rawKey !== "string" || rawKey.length === 0) return "";
return rawKey.slice(-4);
}
/**
* Masked representation from a stored last-4 hint, e.g. "sk-or-v1-••••1234".
*
* @param {string | null | undefined} hint
* @returns {string}
*/
export function maskFromHint(hint) {
if (!hint) return "••••";
return `sk-or-v1-••••${hint}`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Pure request-body shaping for the OpenRouter create-key call. No I/O, no
* `server-only` guard — so it is unit-testable under plain node.
*
* Fields are snake_case per the OpenRouter API ref (`limit_reset`,
* `include_byok_in_limit`, `expires_at`).
*
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string }} params
* @returns {Record<string, unknown>}
*/
export function buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt }) {
return {
name,
limit: limitUsd,
limit_reset: resetPeriod,
include_byok_in_limit: includeByok,
expires_at: expiresAt,
};
}
+96
View File
@@ -0,0 +1,96 @@
import "server-only";
import { buildCreateKeyBody } from "./create-key-request-body";
const OPENROUTER_KEYS_URL = "https://openrouter.ai/api/v1/keys";
/**
* @typedef {Object} CreateKeyResult
* @property {string} key Raw key (sk-or-v1-…). Shown ONCE. Never persist or log it.
* @property {string} hash Delete handle (response `data.hash`) for DELETE /keys/:hash.
*/
/** Error carrying the OpenRouter HTTP status for typed handling (429, etc.). */
export class ProvisioningError extends Error {
/**
* @param {string} message
* @param {number} status
*/
constructor(message, status) {
super(message);
this.name = "ProvisioningError";
this.status = status;
}
}
/**
* Mint a new provisioned key. Request fields are snake_case per the OpenRouter
* API ref (`limit_reset`, `include_byok_in_limit`).
*
* @param {{ name: string, limitUsd: number, resetPeriod: string, includeByok: boolean, expiresAt: string }} params
* @returns {Promise<CreateKeyResult>}
*/
export async function createKey({ name, limitUsd, resetPeriod, includeByok, expiresAt }) {
const res = await fetch(OPENROUTER_KEYS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${requireProvisioningKey()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(buildCreateKeyBody({ name, limitUsd, resetPeriod, includeByok, expiresAt })),
});
if (!res.ok) {
// Do NOT include the response body — it may echo the raw key.
throw new ProvisioningError(`OpenRouter create key failed (${res.status})`, res.status);
}
const json = await res.json();
const key = json?.key;
const hash = json?.data?.hash;
if (!key || !hash) {
throw new ProvisioningError("Unexpected create-key response shape (missing key/data.hash)", res.status);
}
return { key, hash };
}
/**
* Delete (revoke) a key by its hash. 404 is treated as success (idempotent).
*
* @param {string} hash
* @returns {Promise<void>}
*/
export async function deleteKey(hash) {
const res = await fetch(`${OPENROUTER_KEYS_URL}/${encodeURIComponent(hash)}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${requireProvisioningKey()}` },
});
if (!res.ok && res.status !== 404) {
throw new ProvisioningError(`OpenRouter delete key failed (${res.status})`, res.status);
}
}
/**
* List provisioned keys (reconcile script).
*
* @returns {Promise<Array<{ hash: string, name?: string, disabled?: boolean }>>}
*/
export async function listKeys() {
const res = await fetch(OPENROUTER_KEYS_URL, {
headers: { Authorization: `Bearer ${requireProvisioningKey()}` },
});
if (!res.ok) {
throw new ProvisioningError(`OpenRouter list keys failed (${res.status})`, res.status);
}
const json = await res.json();
return json?.data ?? [];
}
/**
* @returns {string}
*/
function requireProvisioningKey() {
const key = process.env.OPENROUTER_PROVISIONING_KEY;
if (!key) throw new ProvisioningError("Missing OPENROUTER_PROVISIONING_KEY", 0);
return key;
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
/**
* Browser Supabase client — anon key only.
*
* SCOPE: auth UI only (sign-in / sign-out / session). This client MUST NEVER be
* used to read or write `llmapikey.api_keys`; that table is server-only and the
* anon role is denied by RLS. See lib/supabase/server-client.js.
*
* @returns {import('@supabase/supabase-js').SupabaseClient}
*/
export function createBrowserSupabaseClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
throw new Error(
"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY",
);
}
return createBrowserClient(url, anonKey);
}
+51
View File
@@ -0,0 +1,51 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
/**
* Cookie-based Supabase client (anon key) for reading/refreshing the auth
* session on the server. Use this to know WHO the request is (Phase 3 identity),
* never to touch `api_keys` — that table lives in the unexposed `llmapikey`
* schema and is reached only via the direct Postgres client (lib/db).
*
* In Server Components cookie writes throw; we swallow that — session refresh
* still works in route handlers / server actions where writes are allowed.
*
* @returns {Promise<import('@supabase/supabase-js').SupabaseClient>}
*/
export async function createServerAuthClient() {
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
const anonKey = requireEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY");
const cookieStore = await cookies();
return createServerClient(url, anonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
} catch {
// Called from a Server Component — cookies are read-only here.
// Safe to ignore: middleware/route handlers refresh the session.
}
},
},
});
}
/**
* @param {string} name
* @returns {string}
*/
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
+41
View File
@@ -0,0 +1,41 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
/**
* Refresh the Supabase auth session on each request so server components and
* actions see a valid token. Reads/writes session cookies via the request and
* response (middleware can't use next/headers cookies()).
*
* @param {import('next/server').NextRequest} request
*/
export async function middleware(request) {
let response = NextResponse.next({ request });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) return response; // no auth configured yet
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
});
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|ico)$).*)"],
};
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;
+5694
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "llmapikey",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "node --test \"tests/*.test.js\""
},
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.45.4",
"next": "^15.1.6",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"eslint": "^9.18.0",
"eslint-config-next": "^15.1.6"
}
}
@@ -1,7 +1,7 @@
---
phase: 2
title: "Scaffold and Config"
status: pending
status: code-complete
priority: P1
effort: "0.5d"
dependencies: [1]
@@ -1,7 +1,7 @@
---
phase: 3
title: "GitHub OAuth Auth Flow"
status: pending
status: code-complete
priority: P1
effort: "0.5d"
dependencies: [2]
@@ -1,7 +1,7 @@
---
phase: 4
title: "Key Provisioning"
status: pending
status: code-complete
priority: P1
effort: "1d"
dependencies: [3]
@@ -1,7 +1,7 @@
---
phase: 5
title: "Pages and UI"
status: pending
status: code-complete
priority: P2
effort: "1d"
dependencies: [4]
@@ -1,7 +1,7 @@
---
phase: 6
title: "Test and Deploy"
status: pending
status: code-complete (live E2E + prod deploy deferred to post-Phase-1)
priority: P2
effort: "0.5d"
dependencies: [5]
@@ -30,12 +30,39 @@ Next.js (App Router, JS + JSDoc) site on Vercel. Gives developers a $10/day-capp
| Phase | Name | Status |
|-------|------|--------|
| 1 | [ToS Approval Gate](./phase-01-tos-approval-gate.md) | Pending |
| 2 | [Scaffold and Config](./phase-02-scaffold-and-config.md) | Pending |
| 3 | [GitHub OAuth Auth Flow](./phase-03-github-oauth-auth-flow.md) | Pending |
| 4 | [Key Provisioning](./phase-04-key-provisioning.md) | Pending |
| 5 | [Pages and UI](./phase-05-pages-and-ui.md) | Pending |
| 6 | [Test and Deploy](./phase-06-test-and-deploy.md) | Pending |
| 1 | [ToS Approval Gate](./phase-01-tos-approval-gate.md) | Pending (BLOCKING — not started) |
| 2 | [Scaffold and Config](./phase-02-scaffold-and-config.md) | Code complete |
| 3 | [GitHub OAuth Auth Flow](./phase-03-github-oauth-auth-flow.md) | Code complete |
| 4 | [Key Provisioning](./phase-04-key-provisioning.md) | Code complete |
| 5 | [Pages and UI](./phase-05-pages-and-ui.md) | Code complete |
| 6 | [Test and Deploy](./phase-06-test-and-deploy.md) | Code+tests done; live E2E + deploy deferred |
## Implementation Status (2026-06-13)
Built code-only at user direction ("just build, I won't deploy yet"); Phase 1
ToS gate intentionally NOT cleared, live minting gated behind
`PROVISIONING_ENABLED=false`, Vercel auto-deploy disabled (`vercel.json`).
**Build/test:** `next build` green (7 routes); `npm test` 6 pass / 1 skip (RLS
test needs live Supabase). Code review: all 8 acceptance criteria verified; 2
HIGH lifecycle bugs found and fixed (stuck-pending lockout recovery; kill-switch
now counts pending+active with post-reserve re-check).
**Deviation from plan (Phase 2):** the `api_keys` table is reached via a direct
Postgres connection (`postgres` npm + `DATABASE_URL`), NOT the service-role
supabase-js client. Reason: the plan required the `llmapikey` schema to stay
unexposed to PostgREST, but supabase-js cannot query an unexposed schema. Direct
PG keeps the schema fully hidden (a stronger realization of the same intent) and
removes the service-role blast-radius concern. Env vars changed accordingly:
`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `DATABASE_URL`
(no `SUPABASE_SERVICE_ROLE_KEY`).
**Still requires (post-Phase-1, before public launch):** apply migration to a
real DB, register GitHub OAuth app + Supabase provider, set live env +
`PROVISIONING_ENABLED=true`, run the RLS deny-all test against real Supabase,
create→delete round-trip on a throwaway key, manual E2E (sign-in → generate →
call model → re-generate idempotent → concurrent double-submit = one key),
dashboard verification (limit/daily-reset/BYOK), and function-log redaction grep.
## Dependencies
+50
View File
@@ -0,0 +1,50 @@
/**
* Reconcile OpenRouter provisioned keys against the DB registry.
*
* Reports:
* - Orphaned OpenRouter keys: minted by this app (name `llmapikey:*`) but with
* no matching DB row → a billable cost leak to revoke.
* - Dangling DB rows: an active row whose key was deleted out-of-band.
* - Stale pending rows: a reservation from an interrupted mint (>10 min old)
* that still has no key — would block that user until reclaimed.
*
* Run (loads server-only secrets from .env.local):
* node --env-file=.env.local scripts/reconcile-keys.js
*/
import { getSql } from "../lib/db/postgres-client.js";
import { listKeys } from "../lib/openrouter/provisioning-client.js";
async function main() {
const sql = getSql();
const [orKeys, dbRows] = await Promise.all([
listKeys(),
sql`select github_user_id, openrouter_key_hash, status, created_at from llmapikey.api_keys`,
]);
const dbHashes = new Set(dbRows.map((r) => r.openrouter_key_hash).filter(Boolean));
const orHashes = new Set(orKeys.map((k) => k.hash));
const appKeys = orKeys.filter((k) => typeof k.name === "string" && k.name.startsWith("llmapikey:"));
const orphans = appKeys.filter((k) => !dbHashes.has(k.hash));
const dangling = dbRows.filter((r) => r.openrouter_key_hash && !orHashes.has(r.openrouter_key_hash));
const tenMinAgo = Date.now() - 10 * 60 * 1000;
const stalePending = dbRows.filter(
(r) => r.status === "pending" && new Date(r.created_at).getTime() < tenMinAgo,
);
console.log(`OpenRouter app keys: ${appKeys.length} | DB rows: ${dbRows.length}`);
console.log(`Orphaned OpenRouter keys (no DB row → cost leak): ${orphans.length}`);
for (const k of orphans) console.log(` orphan hash=${k.hash} name=${k.name}`);
console.log(`Dangling DB rows (key deleted out-of-band): ${dangling.length}`);
for (const r of dangling) console.log(` dangling github_user_id=${r.github_user_id} hash=${r.openrouter_key_hash}`);
console.log(`Stale pending rows (interrupted mint, blocks user): ${stalePending.length}`);
for (const r of stalePending) console.log(` stale-pending github_user_id=${r.github_user_id} created_at=${r.created_at}`);
await sql.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,4 @@
-- Rollback for 0001. Vercel rollback does NOT revert the database — run this
-- manually against the same DB if the migration must be undone.
drop table if exists llmapikey.api_keys;
drop schema if exists llmapikey;
@@ -0,0 +1,29 @@
-- Dedicated schema for the spending-key registry. Intentionally NOT added to
-- PostgREST's exposed schemas, so the REST API / anon role can never reach it.
-- All app access is via a direct Postgres connection (lib/db/postgres-client.js).
create schema if not exists llmapikey;
create table if not exists llmapikey.api_keys (
id uuid primary key default gen_random_uuid(),
-- Numeric, immutable GitHub id (provider_id). Unique constraint is the
-- one-key-per-account invariant and the concurrency guard for reserve-then-mint.
github_user_id text not null unique,
github_username text not null,
-- OpenRouter delete handle (response `data.hash`), NOT the raw key and NOT `data.id`.
-- Nullable: a row is reserved (status 'pending') before the key is minted.
openrouter_key_hash text,
-- Last 4 chars of the raw key for masked display. Raw key is never stored.
key_hint text,
status text not null default 'pending'
check (status in ('pending', 'active')),
created_at timestamptz not null default now()
);
-- Defense in depth. The schema is unexposed (primary control), but enable RLS
-- with NO policies so that even if the schema were ever exposed, anon /
-- authenticated get zero rows. The direct PG owner connection bypasses RLS.
alter table llmapikey.api_keys enable row level security;
-- Belt-and-suspenders: strip any default grants from the API roles.
revoke all on schema llmapikey from anon, authenticated;
revoke all on all tables in schema llmapikey from anon, authenticated;
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { last4, maskFromHint } from "../lib/keys/key-format.js";
test("last4 returns the final four characters", () => {
assert.equal(last4("sk-or-v1-abcd1234"), "1234");
});
test("last4 handles short and empty input", () => {
assert.equal(last4("xy"), "xy");
assert.equal(last4(""), "");
assert.equal(last4(/** @type {any} */ (undefined)), "");
});
test("maskFromHint builds a masked string from the hint", () => {
assert.equal(maskFromHint("1234"), "sk-or-v1-••••1234");
});
test("maskFromHint degrades gracefully without a hint", () => {
assert.equal(maskFromHint(null), "••••");
assert.equal(maskFromHint(""), "••••");
});
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildCreateKeyBody } from "../lib/openrouter/create-key-request-body.js";
test("create-key body uses OpenRouter snake_case fields", () => {
const body = buildCreateKeyBody({
name: "llmapikey:12345",
limitUsd: 10,
resetPeriod: "daily",
includeByok: true,
expiresAt: "2026-09-11T00:00:00.000Z",
});
assert.deepEqual(body, {
name: "llmapikey:12345",
limit: 10,
limit_reset: "daily",
include_byok_in_limit: true,
expires_at: "2026-09-11T00:00:00.000Z",
});
});
test("create-key body has no camelCase leakage", () => {
const body = buildCreateKeyBody({
name: "x",
limitUsd: 5,
resetPeriod: "daily",
includeByok: false,
expiresAt: "2026-01-01T00:00:00.000Z",
});
const keys = Object.keys(body);
assert.ok(!keys.includes("limitReset"));
assert.ok(!keys.includes("includeByok"));
assert.ok(!keys.includes("expiresAt"));
});
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
/**
* Real-Supabase verification: the anon role must NOT be able to read api_keys.
* Because `llmapikey` is unexposed to PostgREST, the anon client should get a
* schema/permission error (or, defensively, zero rows) — never real data.
*
* Skips when Supabase env is absent so `npm test` passes in CI/local without
* credentials. Run against a real project to actually exercise the invariant.
*/
test(
"anon client cannot read llmapikey.api_keys",
{ skip: !url || !anonKey ? "Supabase env not set" : false },
async () => {
const supabase = createClient(url, anonKey, {
auth: { persistSession: false },
});
const { data, error } = await supabase
.schema("llmapikey")
.from("api_keys")
.select("id");
// The schema is unexposed to PostgREST, so the anon client MUST get an
// error (e.g. PGRST106). An empty array is NOT acceptable — that would mean
// the schema became reachable, just with no rows visible.
assert.notEqual(error, null, "anon must receive a hard error, not a result set");
assert.equal(data, null, "anon must not receive any data array");
},
);
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": {
"deploymentEnabled": false
}
}