From e4177fac8fa06b17bc3983428b3806afaa80a15f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 19 Jun 2026 14:34:35 +0700 Subject: [PATCH] feat: add pending project gate --- .env.example | 8 ++- README.md | 46 ++++++++++--- app/actions/admin-keys.js | 15 +++-- app/actions/generate-key.js | 11 +-- app/dashboard/page.js | 27 +++++--- app/docs/page.js | 22 ++++-- app/globals.css | 4 ++ app/layout.js | 4 +- app/page.js | 28 ++++++++ components/generate-key-panel.js | 18 ++++- docs/project-status.md | 56 ++++++++++++++++ lib/project-status.js | 67 +++++++++++++++++++ .../plan.md | 41 ++++++------ tests/project-status.test.js | 59 ++++++++++++++++ 14 files changed, 348 insertions(+), 58 deletions(-) create mode 100644 docs/project-status.md create mode 100644 lib/project-status.js create mode 100644 tests/project-status.test.js diff --git a/.env.example b/.env.example index f002967..ba4f28b 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,12 @@ OPENROUTER_MANAGEMENT_KEY=sk-or-v1-provisioning-... OPENROUTER_WORKSPACE_ID=33179556-3ab3-40a4-af8b-211d322aa94e # ---- 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. +# Project lifecycle. Fail-closed: unset/unknown values are treated as "pending". +# Keep "pending" until a provider can safely support the free monthly giveaway. +# Set to "live" only after provider and billing risks are accepted. +PROJECT_STATUS=pending +# Feature flag: even when PROJECT_STATUS=live, live key minting stays OFF unless +# this is also "true". When "false", generateKey() refuses to mint. PROVISIONING_ENABLED=false # Sybil/abuse kill-switch: stop minting once this many active keys exist. MAX_TOTAL_KEYS=500 diff --git a/README.md b/README.md index bcd869d..cbee8cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # llmapikey -Free, capped OpenRouter API key giveaway — one key per GitHub account. +Pending 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. @@ -8,9 +8,11 @@ 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. +> **Status:** pending. Live key minting is gated behind +> `PROJECT_STATUS=pending` and `PROVISIONING_ENABLED=false` until a suitable +> provider is found. Official OpenRouter docs confirm BYOK has a 5% OpenRouter +> fee after the first 1M BYOK requests per month (requests, not tokens), so this +> giveaway is paused before public launch. ## Stack @@ -36,6 +38,9 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed - **Schema isolation is structural:** the `llmapikey` schema 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. +- **Project lifecycle gate:** `PROJECT_STATUS` defaults fail-closed to + `pending`; the UI hides self-serve key minting/retrieval while pending, and key + creation requires `PROJECT_STATUS=live` plus `PROVISIONING_ENABLED=true`. - **Reserve-then-mint:** a `pending` row 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_key` so users can copy it @@ -64,7 +69,8 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed | `POSTGRES_URL` | Supabase **transaction pooler** string (server-only; provisioned by the Supabase Vercel integration) | | `OPENROUTER_MANAGEMENT_KEY` | Master management/provisioning key (server-only) | | `OPENROUTER_WORKSPACE_ID` | Workspace minted keys are created in (create-key `workspace_id`); omit for the management key's default | - | `PROVISIONING_ENABLED` | `false` until Phase 1 ToS gate clears | + | `PROJECT_STATUS` | `pending` until a suitable provider is found; only `live` can enable the product | + | `PROVISIONING_ENABLED` | Lower-level minting flag; must be `true` in addition to `PROJECT_STATUS=live` | | `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`) | @@ -94,10 +100,34 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed npm test # unit tests ``` +## OpenRouter BYOK finding + +As of 2026-06-19, the rendered official OpenRouter docs say BYOK usage has a +fee after the monthly waiver: the first 1M BYOK requests per month are free, +then OpenRouter charges 5% of what the same model/provider would normally cost +on OpenRouter, deducted from OpenRouter credits. This is documented as requests, +not tokens. + +Independent research also confirmed the policy shape (request-based monthly +waiver, then percentage fee from OpenRouter credits), but saw placeholders in +OpenRouter's markdown docs for the exact threshold/percentage. Re-verify the +rendered docs or ask OpenRouter support before setting `PROJECT_STATUS=live`. + +Implication: the public free-key giveaway stays pending until a provider can +support the intended economics without surprise pass-through charges. See +[`docs/project-status.md`](docs/project-status.md). + +Sources: + +- https://openrouter.ai/docs/guides/overview/auth/byok +- https://openrouter.ai/docs/faq + ## 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. +live key minting is gated independently by `PROJECT_STATUS` and +`PROVISIONING_ENABLED`. Keep `PROJECT_STATUS=pending` and +`PROVISIONING_ENABLED=false` in the Vercel environment until provider economics +are accepted; only then set `PROJECT_STATUS=live` and `PROVISIONING_ENABLED=true` +to begin minting. diff --git a/app/actions/admin-keys.js b/app/actions/admin-keys.js index 8095485..774d957 100644 --- a/app/actions/admin-keys.js +++ b/app/actions/admin-keys.js @@ -6,6 +6,7 @@ import { requireAdminIdentity } from "@/lib/auth/is-admin"; import * as repo from "@/lib/keys/api-keys-repository"; import { mintAndPersist, numEnv } from "@/lib/keys/mint-key"; import { deleteKey } from "@/lib/openrouter/provisioning-client"; +import { keyMintingGateMessage } from "@/lib/project-status"; /** * Admin actions over the `api_keys` registry. EVERY action re-gates with @@ -49,8 +50,9 @@ export async function revokeKey(id) { /** * Manually mint a key for an arbitrary GitHub user (admin override). Reuses the - * shared reserve→mint→activate flow and honors PROVISIONING_ENABLED and the - * MAX_TOTAL_KEYS ceiling, exactly like the self-serve path. + * shared reserve→mint→activate flow and honors PROJECT_STATUS, + * PROVISIONING_ENABLED, and the MAX_TOTAL_KEYS ceiling, exactly like the + * self-serve path. * * @param {{ githubUserId: string, githubUsername: string }} params * @returns {Promise} @@ -66,16 +68,17 @@ export async function adminCreateKey({ githubUserId, githubUsername } = {}) { } const username = String(githubUsername ?? "").trim() || userId; + const gateMessage = keyMintingGateMessage(); + if (gateMessage) { + return { status: "error", message: gateMessage }; + } + // Idempotency: an existing active key is never re-minted. const existing = await repo.findByGithubUserId(userId); if (existing && existing.status === "active") { return { status: "exists", keyHint: existing.key_hint, message: "User already has a key." }; } - if (process.env.PROVISIONING_ENABLED !== "true") { - return { status: "error", message: "Key giveaway is not live yet. Check back soon." }; - } - const maxKeys = numEnv("MAX_TOTAL_KEYS", 0); if (maxKeys > 0 && (await repo.countLiveKeys()) >= maxKeys) { return { status: "error", message: "Key limit reached. Cannot mint more keys." }; diff --git a/app/actions/generate-key.js b/app/actions/generate-key.js index 27f2ebd..2479a35 100644 --- a/app/actions/generate-key.js +++ b/app/actions/generate-key.js @@ -5,6 +5,7 @@ import "server-only"; import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity"; import * as repo from "@/lib/keys/api-keys-repository"; import { mintAndPersist, numEnv } from "@/lib/keys/mint-key"; +import { keyMintingGateMessage } from "@/lib/project-status"; /** * @typedef {Object} GenerateKeyResult @@ -34,17 +35,17 @@ export async function generateKey() { return { status: "error", message: "Sign in with GitHub first." }; } + const gateMessage = keyMintingGateMessage(); + if (gateMessage) { + return { status: "error", message: gateMessage }; + } + // Idempotency fast-path: existing active key → return it, never mint again. const existing = await repo.findByGithubUserId(identity.githubUserId); if (existing && existing.status === "active") { return { status: "exists", rawKey: existing.openrouter_key, 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) { diff --git a/app/dashboard/page.js b/app/dashboard/page.js index ee2bcf0..afeed8f 100644 --- a/app/dashboard/page.js +++ b/app/dashboard/page.js @@ -2,6 +2,7 @@ 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"; +import { keyMintingGateMessage } from "@/lib/project-status"; // Reads the session per request — never prerender. export const dynamic = "force-dynamic"; @@ -11,6 +12,7 @@ export const dynamic = "force-dynamic"; * generate / existing-key panel. */ export default async function DashboardPage() { + const disabledReason = keyMintingGateMessage(); const identity = await getCurrentGithubIdentity(); if (!identity) { @@ -18,20 +20,22 @@ export default async function DashboardPage() {

Dashboard

-

Sign in with GitHub to get your free key.

- +

{disabledReason || "Sign in with GitHub to get your free key."}

+ {!disabledReason && }
); } let existingKey = null; - try { - const row = await repo.findByGithubUserId(identity.githubUserId); - if (row && row.status === "active") existingKey = row.openrouter_key; - } catch { - // DB not reachable (e.g. local without POSTGRES_URL) — show the panel; the - // server action gates minting and reports a friendly error. + if (!disabledReason) { + try { + const row = await repo.findByGithubUserId(identity.githubUserId); + if (row && row.status === "active") existingKey = row.openrouter_key; + } catch { + // DB not reachable (e.g. local without POSTGRES_URL) — show the panel; the + // server action gates minting and reports a friendly error. + } } const model = "minimax/minimax-m3"; @@ -41,7 +45,12 @@ export default async function DashboardPage() {

Your key

Signed in as @{identity.githubUsername}.

- +
); } diff --git a/app/docs/page.js b/app/docs/page.js index 4a2ee7c..d332662 100644 --- a/app/docs/page.js +++ b/app/docs/page.js @@ -1,10 +1,14 @@ +import { isProjectLive, projectPendingMessage } from "@/lib/project-status"; + export const metadata = { title: "Docs — llmapikey" }; +export const dynamic = "force-dynamic"; /** * Static usage guide: how to call OpenRouter with the issued key. */ export default function DocsPage() { const model = "minimax/minimax-m3"; + const projectLive = isProjectLive(); const curlExample = `curl https://openrouter.ai/api/v1/chat/completions \\ -H "Authorization: Bearer $OPENROUTER_KEY" \\ @@ -31,10 +35,16 @@ console.log(data.choices[0].message.content);`; return (

Using your key

+ {!projectLive && ( +
+

Project status: pending.

+

{projectPendingMessage()}

+
+ )}

- Your key is a standard OpenRouter API key. Base URL:{" "} - https://openrouter.ai/api/v1. Authenticate with{" "} - Authorization: Bearer <your-key>. + {projectLive ? "Your key is" : "Previously issued keys are"} a standard + OpenRouter API key. Base URL: https://openrouter.ai/api/v1. + Authenticate with Authorization: Bearer <your-key>.

Model

@@ -51,7 +61,11 @@ console.log(data.choices[0].message.content);`;

Limits

  • Capped at $10/day, resetting daily.
  • -
  • Your key is stored — copy it again anytime from your dashboard.
  • +
  • + {projectLive + ? "Your key is stored — copy it again anytime from your dashboard." + : "Stored-key retrieval is paused while the project is pending."} +
  • One key per GitHub account.
diff --git a/app/globals.css b/app/globals.css index 3107b26..6b1feb9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -66,6 +66,10 @@ header.site-header { margin: 1rem 0; } +.status-panel { + border-color: rgba(240, 180, 41, 0.55); +} + .btn { display: inline-block; background: var(--accent-strong); diff --git a/app/layout.js b/app/layout.js index 5263ffa..ef6a45e 100644 --- a/app/layout.js +++ b/app/layout.js @@ -3,9 +3,9 @@ import "./globals.css"; import { SiteHeader } from "@/components/site-header"; export const metadata = { - title: "llmapikey — free OpenRouter API key", + title: "llmapikey — pending OpenRouter API key giveaway", description: - "Get a free, capped OpenRouter API key — one per GitHub account. No signup beyond GitHub.", + "Pending free, capped OpenRouter API key giveaway — paused until a suitable provider is selected.", }; /** diff --git a/app/page.js b/app/page.js index 1c3891d..b681938 100644 --- a/app/page.js +++ b/app/page.js @@ -1,6 +1,9 @@ import Link from "next/link"; import { SignInWithGithubButton } from "@/components/sign-in-with-github-button"; +import { isProjectLive, projectPendingMessage } from "@/lib/project-status"; + +export const dynamic = "force-dynamic"; /** * Landing page. Static copy + sign-in CTA. Star nudge and how-it-works are @@ -9,6 +12,31 @@ import { SignInWithGithubButton } from "@/components/sign-in-with-github-button" export default function HomePage() { const repoUrl = "https://github.com/tiennm99/llmapikey"; const model = "minimax/minimax-m3"; + const projectLive = isProjectLive(); + + if (!projectLive) { + return ( +
+

llmapikey is pending

+

+ The free OpenRouter key giveaway is paused while we look for a provider + that can support this safely. +

+ +
+

{projectPendingMessage()}

+

+ OpenRouter BYOK remains useful for personal routing, but the current + pass-through fee model makes a public free-key giveaway unsafe. +

+
+ +

+ Read the current status notes → +

+
+ ); + } return (
diff --git a/components/generate-key-panel.js b/components/generate-key-panel.js index 5f02c3b..def8d0c 100644 --- a/components/generate-key-panel.js +++ b/components/generate-key-panel.js @@ -9,9 +9,9 @@ import { KeyDisplay } from "./key-display"; * Generate / existing-key panel. Calls the server action; renders the full key * (retrievable) whether it was just created or already existed. * - * @param {{ existingKey: string|null, model: string, repoUrl: string }} props + * @param {{ existingKey: string|null, model: string, repoUrl: string, disabledReason?: string|null }} props */ -export function GenerateKeyPanel({ existingKey, model, repoUrl }) { +export function GenerateKeyPanel({ existingKey, model, repoUrl, disabledReason = null }) { const [state, setState] = useState( existingKey ? { status: "exists", rawKey: existingKey } @@ -31,6 +31,20 @@ export function GenerateKeyPanel({ existingKey, model, repoUrl }) { return ; } + if (disabledReason) { + return ( +
+ +

{disabledReason}

+

+ ⭐ Star the repo if you want to follow progress. +

+
+ ); + } + return (