Keys are stored and retrievable now, so landing + docs no longer say 'shown once'. README setup applies migrations 0001-0003 in order, drops a duplicate schema-isolation bullet, and documents key storage + workspace minting.
llmapikey
Free, capped OpenRouter API key giveaway — one key per GitHub account.
Next.js (App Router, JS + JSDoc) on Vercel. App-native GitHub OAuth (Arctic +
jose stateless signed-cookie session) — Supabase is the Postgres host only.
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=falseuntil 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)
- App-native GitHub OAuth — Arctic for the OAuth2
dance, jose for a stateless HS256 signed-cookie
session. No auth provider; GitHub →
/auth/callbackdirectly.read:userscope only; the access token is read once and discarded (no token storage). - Postgres (
postgresnpm) — direct connection to the unexposedllmapikeyschema; the anon role can never reachapi_keys. (Supabase = DB host only.) - OpenRouter Provisioning API — mints per-user keys
Architecture notes
- Identity anchor: the numeric, immutable GitHub
provider_id(not the mutable login). One key pergithub_user_id, enforced by a DB unique constraint. - Server-only secrets:
POSTGRES_URL,OPENROUTER_MANAGEMENT_KEY,GITHUB_OAUTH_CLIENT_SECRET, andAUTH_SESSION_SECRETare guarded by theserver-onlypackage and never reach the client bundle (noNEXT_PUBLIC_). - Stateless session: signed JWT (HS256) in an httpOnly+Secure+SameSite=Lax
cookie; no DB session table. CSRF handled by a single-use
statecookie verified at the callback;nextredirects pass through a same-origin sanitizer. - Schema isolation is structural: the
llmapikeyschema is unexposed to PostgREST and reached only via the direct PG client (deny-all RLS as defense in depth). The app ships no anon DB client, so the isolation holds by construction. - Reserve-then-mint: a
pendingrow is inserted (ON CONFLICT DO NOTHING) before minting, so concurrent double-submits yield exactly one OpenRouter key. - Key storage: the raw key is stored in
openrouter_keyso users can copy it again from the dashboard;openrouter_delete_hashis OpenRouter's revoke handle (not a hash of the key). Keys are minted into theOPENROUTER_WORKSPACE_IDworkspace. - Admin console: route
/admin(unlisted — no nav link) lists, searches, filters, revokes, and manually mints keys. Access is gated by theADMIN_GITHUB_USER_IDSallowlist against the same numericprovider_idanchor; non-admins getnotFound()(a 404, never a redirect that would leak the route's existence). Every admin server action re-checks the allowlist server-side, so the page gate is defense-in-depth only.
Setup
- Install
npm install - Environment — copy
.env.exampleto.env.localand fill in values.Var Purpose GITHUB_OAUTH_CLIENT_IDGitHub OAuth App client id (server-only) GITHUB_OAUTH_CLIENT_SECRETGitHub OAuth App client secret (server-only) AUTH_SESSION_SECRETSession JWT signing secret, ≥32 bytes ( openssl rand -base64 48)POSTGRES_URLSupabase transaction pooler string (server-only; provisioned by the Supabase Vercel integration) OPENROUTER_MANAGEMENT_KEYMaster management/provisioning key (server-only) OPENROUTER_WORKSPACE_IDWorkspace minted keys are created in (create-key workspace_id); omit for the management key's defaultPROVISIONING_ENABLEDfalseuntil Phase 1 ToS gate clearsMAX_TOTAL_KEYSKill-switch: stop minting past N active keys KEY_DAILY_LIMIT_USDPer-key daily cap sent to OpenRouter KEY_EXPIRY_DAYSKey lifetime (sets expires_at)ADMIN_GITHUB_USER_IDSAdmin allowlist — numeric GitHub provider_ids, CSV (server-only) - Database — apply all migrations in order to a staging branch first,
then prod (Supabase SQL editor,
psql "$POSTGRES_URL" -f ..., ornode --env-file=.env.local scripts/run-migration.mjs <file>):Do NOT addpsql "$POSTGRES_URL" -f supabase/migrations/0001_llmapikey_schema_and_api_keys.up.sql psql "$POSTGRES_URL" -f supabase/migrations/0002_api_keys_store_raw_key.up.sql psql "$POSTGRES_URL" -f supabase/migrations/0003_rename_key_hash_to_delete_hash.up.sqlllmapikeyto the project's PostgREST "Exposed schemas". Rollback: run the matching*.down.sqlfiles in reverse order (0003 → 0001). - GitHub OAuth App — register one at GitHub > Settings > Developer settings >
OAuth Apps. Authorization callback URL points at the app (not Supabase):
https://llmapikey.vercel.app/auth/callbackfor prod (use a separate dev app withhttp://localhost:3000/auth/callbackfor local). Copy the Client ID + Secret intoGITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRET. The callback is derived from the request origin at runtime, so no callback env is needed — but reach the app on its canonical domain (the one registered above); GitHub rejects sign-in arriving on any other host (e.g. Vercel deployment-hash or preview URLs). - Run
npm run dev # http://localhost:3000 npm test # unit tests
Deploy (gated)
Git-triggered builds on Vercel are enabled (vercel.json →
git.deploymentEnabled: true). Builds and deploys do NOT start the giveaway:
live key minting is gated independently by PROVISIONING_ENABLED. Keep
PROVISIONING_ENABLED=false in the Vercel environment until the Phase 1
OpenRouter ToS gate clears; only then set it true to begin minting.