mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-08-01 10:20:49 +00:00
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:
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <your-key></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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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's okay —
|
||||
it'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"checkJs": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022"
|
||||
},
|
||||
"include": ["**/*.js", "**/*.jsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)$).*)"],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+5694
File diff suppressed because it is too large
Load Diff
@@ -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
-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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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(""), "••••");
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"git": {
|
||||
"deploymentEnabled": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user