mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-09-03 04:18:28 +00:00
feat: add pending project gate
This commit is contained in:
+6
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AdminActionResult>}
|
||||
@@ -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." };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+18
-9
@@ -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() {
|
||||
<main>
|
||||
<h1>Dashboard</h1>
|
||||
<div className="panel">
|
||||
<p>Sign in with GitHub to get your free key.</p>
|
||||
<SignInWithGithubButton next="/dashboard" />
|
||||
<p>{disabledReason || "Sign in with GitHub to get your free key."}</p>
|
||||
{!disabledReason && <SignInWithGithubButton next="/dashboard" />}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<main>
|
||||
<h1>Your key</h1>
|
||||
<p className="muted">Signed in as @{identity.githubUsername}.</p>
|
||||
<GenerateKeyPanel existingKey={existingKey} model={model} repoUrl={repoUrl} />
|
||||
<GenerateKeyPanel
|
||||
existingKey={existingKey}
|
||||
model={model}
|
||||
repoUrl={repoUrl}
|
||||
disabledReason={disabledReason}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-4
@@ -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 (
|
||||
<main>
|
||||
<h1>Using your key</h1>
|
||||
{!projectLive && (
|
||||
<div className="panel status-panel">
|
||||
<p className="warn">Project status: pending.</p>
|
||||
<p className="muted">{projectPendingMessage()}</p>
|
||||
</div>
|
||||
)}
|
||||
<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>.
|
||||
{projectLive ? "Your key is" : "Previously issued keys are"} 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>
|
||||
@@ -51,7 +61,11 @@ console.log(data.choices[0].message.content);`;
|
||||
<h2>Limits</h2>
|
||||
<ul>
|
||||
<li>Capped at $10/day, resetting daily.</li>
|
||||
<li>Your key is stored — copy it again anytime from your dashboard.</li>
|
||||
<li>
|
||||
{projectLive
|
||||
? "Your key is stored — copy it again anytime from your dashboard."
|
||||
: "Stored-key retrieval is paused while the project is pending."}
|
||||
</li>
|
||||
<li>One key per GitHub account.</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-2
@@ -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.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+28
@@ -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 (
|
||||
<main>
|
||||
<h1>llmapikey is pending</h1>
|
||||
<p>
|
||||
The free OpenRouter key giveaway is paused while we look for a provider
|
||||
that can support this safely.
|
||||
</p>
|
||||
|
||||
<div className="panel status-panel">
|
||||
<p className="warn">{projectPendingMessage()}</p>
|
||||
<p className="muted">
|
||||
OpenRouter BYOK remains useful for personal routing, but the current
|
||||
pass-through fee model makes a public free-key giveaway unsafe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<Link href="/docs">Read the current status notes →</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
@@ -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 <KeyDisplay rawKey={state.rawKey} model={model} />;
|
||||
}
|
||||
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<div className="panel">
|
||||
<button className="btn" disabled>
|
||||
Project pending
|
||||
</button>
|
||||
<p className="warn">{disabledReason}</p>
|
||||
<p className="muted">
|
||||
⭐ <a href={repoUrl}>Star the repo</a> if you want to follow progress.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<button className="btn" onClick={onGenerate} disabled={loading}>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Project Status
|
||||
|
||||
## Overview
|
||||
|
||||
Status: pending as of 2026-06-19.
|
||||
|
||||
`llmapikey` is paused until a provider can safely support a public free monthly
|
||||
API-key giveaway without unexpected pass-through costs.
|
||||
|
||||
## Current Gate
|
||||
|
||||
- `PROJECT_STATUS=pending` keeps the product pending and hides self-serve key
|
||||
minting/retrieval in the UI so spend-capable keys are not surfaced while the
|
||||
provider decision is unresolved.
|
||||
- `PROVISIONING_ENABLED=false` remains the lower-level minting kill switch.
|
||||
- Key creation requires both `PROJECT_STATUS=live` and
|
||||
`PROVISIONING_ENABLED=true`.
|
||||
|
||||
## OpenRouter Finding
|
||||
|
||||
Official OpenRouter docs confirm BYOK is not unbounded free usage through
|
||||
OpenRouter:
|
||||
|
||||
- BYOK docs: the rendered page currently says custom provider keys cost 5% of
|
||||
what the same model/provider would cost normally on OpenRouter, deducted from
|
||||
OpenRouter credits, with the first 1M BYOK requests per month waived.
|
||||
- FAQ: the rendered page frames the waived unit as BYOK requests per month, not
|
||||
tokens.
|
||||
- Independent research confirmed the policy shape, 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`.
|
||||
- Free model docs: free models and the free router can be zero cost, but they
|
||||
have low rate limits/availability and are not a fit for a public giveaway that
|
||||
promises stable model access.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://openrouter.ai/docs/guides/overview/auth/byok
|
||||
- https://openrouter.ai/docs/faq
|
||||
- https://openrouter.ai/docs/guides/routing/routers/free-router
|
||||
- https://openrouter.ai/docs/guides/routing/model-variants/free
|
||||
|
||||
## Reopen Criteria
|
||||
|
||||
- Suitable provider found and accepted.
|
||||
- OpenRouter BYOK exact threshold/fee re-verified if OpenRouter remains in scope.
|
||||
- Billing behavior verified for public free-key usage.
|
||||
- Monthly and per-key caps verified against real provider controls.
|
||||
- `PROJECT_STATUS=live` and `PROVISIONING_ENABLED=true` set deliberately in
|
||||
runtime env.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Which provider can support the same free monthly giveaway economics safely?
|
||||
- Whether any previously minted OpenRouter keys need manual revoke in the
|
||||
OpenRouter dashboard.
|
||||
@@ -0,0 +1,67 @@
|
||||
const LIVE_STATUS = "live";
|
||||
const PENDING_STATUS = "pending";
|
||||
|
||||
export const PROJECT_STATUS_ENV = "PROJECT_STATUS";
|
||||
|
||||
export const PROJECT_STATUS = {
|
||||
LIVE: LIVE_STATUS,
|
||||
PENDING: PENDING_STATUS,
|
||||
};
|
||||
|
||||
const PENDING_MESSAGE =
|
||||
"llmapikey is pending while we evaluate a provider that can support a free monthly key giveaway without surprise BYOK pass-through fees.";
|
||||
|
||||
/**
|
||||
* Runtime project lifecycle. Defaults fail-closed so a missing or mistyped env
|
||||
* cannot accidentally reopen key minting.
|
||||
*
|
||||
* @returns {"live"|"pending"}
|
||||
*/
|
||||
export function getProjectStatus() {
|
||||
return normalizeStatus(process.env[PROJECT_STATUS_ENV]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isProjectLive() {
|
||||
return getProjectStatus() === LIVE_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isKeyMintingEnabled() {
|
||||
return isProjectLive() && process.env.PROVISIONING_ENABLED === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-facing explanation for the first disabled gate, or null when minting is
|
||||
* enabled.
|
||||
*
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function keyMintingGateMessage() {
|
||||
if (!isProjectLive()) return PENDING_MESSAGE;
|
||||
if (process.env.PROVISIONING_ENABLED !== "true") {
|
||||
return "Key giveaway is not live yet. Check back soon.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function projectPendingMessage() {
|
||||
return PENDING_MESSAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {"live"|"pending"}
|
||||
*/
|
||||
function normalizeStatus(value) {
|
||||
return String(value ?? "").trim().toLowerCase() === LIVE_STATUS
|
||||
? LIVE_STATUS
|
||||
: PENDING_STATUS;
|
||||
}
|
||||
@@ -16,13 +16,13 @@ source: skill
|
||||
|
||||
## Overview
|
||||
|
||||
Next.js (App Router, JS + JSDoc) site on Vercel. Gives developers a $10/day-capped OpenRouter API key — one per GitHub account — minted from owner's master Provisioning key, backed by owner's BYOK monthly subscription. Supabase only (dedicated `llmapikey` schema in a shared project, server-only access) for auth + key records. No proxy: OpenRouter enforces the cap. Soft star nudge (not enforced).
|
||||
Next.js (App Router, JS + JSDoc) site on Vercel. Intended to give developers a $10/day-capped API key — one per GitHub account — minted from owner's master key, backed by a suitable provider. The original OpenRouter BYOK-backed path is pending because OpenRouter now documents a 5% BYOK fee after the first 1M BYOK requests per month. Supabase only (dedicated `llmapikey` schema in a shared project, server-only access) for auth + key records. No proxy.
|
||||
|
||||
**Cost model caveat (verify in Phase 1):** "free to users" — owner pays. OpenRouter BYOK still bills ~5% of model cost to OpenRouter credits and can fall back to paid endpoints, so "strictly free for the owner" is UNVERIFIED. The "$10/day" cap basis and zero-balance BYOK behavior are open questions for OpenRouter support (Phase 1). Owner's subscription cap is the global $ ceiling.
|
||||
|
||||
**Source:** `plans/reports/brainstorm-summary-260613-1144-openrouter-key-giveaway-website-report.md`
|
||||
|
||||
**GATING:** Phase 1 (ToS Approval Gate) blocks all build phases. If OpenRouter support rejects the free key-giveaway model, the plan is **cancelled** — do not build.
|
||||
**GATING:** Project pending until a suitable provider is found. Runtime key creation requires `PROJECT_STATUS=live` and `PROVISIONING_ENABLED=true`. Keep both closed while provider economics are unresolved.
|
||||
|
||||
**Scope OUT (v1):** proxy, payments, GLM/Kimi, per-model restriction, admin analytics, star enforcement.
|
||||
|
||||
@@ -37,32 +37,33 @@ Next.js (App Router, JS + JSDoc) site on Vercel. Gives developers a $10/day-capp
|
||||
| 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)
|
||||
## Implementation Status (2026-06-19)
|
||||
|
||||
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`).
|
||||
Scaffold, app-native GitHub OAuth, DB migrations, key mint/reconcile/admin flows,
|
||||
pages, and tests are code-complete in this repo, but public launch is pending.
|
||||
|
||||
**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).
|
||||
Provider economics gate intentionally NOT cleared. Live minting is gated behind
|
||||
`PROJECT_STATUS=pending` and `PROVISIONING_ENABLED=false`.
|
||||
|
||||
**Build/test:** historical `next build` green (7 routes); `npm test` 6 pass / 1
|
||||
skip before this status update. Current verification should be rerun after every
|
||||
status-gate change.
|
||||
|
||||
**Deviation from plan (Phase 2):** the `api_keys` table is reached via a direct
|
||||
Postgres connection (`postgres` npm + `DATABASE_URL`), NOT the service-role
|
||||
Postgres connection (`postgres` npm + `POSTGRES_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`).
|
||||
removes the service-role blast-radius concern.
|
||||
|
||||
**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.
|
||||
**Still requires, before public launch:** find/approve provider economics,
|
||||
re-verify any OpenRouter BYOK thresholds/fees if OpenRouter remains in scope,
|
||||
apply migration to a real DB, register GitHub OAuth app, set live env
|
||||
(`PROJECT_STATUS=live`, `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/billing),
|
||||
and function-log redaction grep.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
getProjectStatus,
|
||||
isKeyMintingEnabled,
|
||||
keyMintingGateMessage,
|
||||
PROJECT_STATUS,
|
||||
} from "../lib/project-status.js";
|
||||
|
||||
function withEnv(values, fn) {
|
||||
const previous = {};
|
||||
for (const key of Object.keys(values)) {
|
||||
previous[key] = process.env[key];
|
||||
if (values[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = values[key];
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(values)) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("project status defaults to pending when env is missing or unknown", () => {
|
||||
withEnv({ PROJECT_STATUS: undefined }, () => {
|
||||
assert.equal(getProjectStatus(), PROJECT_STATUS.PENDING);
|
||||
});
|
||||
|
||||
withEnv({ PROJECT_STATUS: "enabled" }, () => {
|
||||
assert.equal(getProjectStatus(), PROJECT_STATUS.PENDING);
|
||||
});
|
||||
});
|
||||
|
||||
test("project status accepts live case-insensitively", () => {
|
||||
withEnv({ PROJECT_STATUS: " LIVE " }, () => {
|
||||
assert.equal(getProjectStatus(), PROJECT_STATUS.LIVE);
|
||||
});
|
||||
});
|
||||
|
||||
test("key minting requires both live project status and provisioning enabled", () => {
|
||||
withEnv({ PROJECT_STATUS: "pending", PROVISIONING_ENABLED: "true" }, () => {
|
||||
assert.equal(isKeyMintingEnabled(), false);
|
||||
assert.match(keyMintingGateMessage(), /pending/);
|
||||
});
|
||||
|
||||
withEnv({ PROJECT_STATUS: "live", PROVISIONING_ENABLED: "false" }, () => {
|
||||
assert.equal(isKeyMintingEnabled(), false);
|
||||
assert.equal(keyMintingGateMessage(), "Key giveaway is not live yet. Check back soon.");
|
||||
});
|
||||
|
||||
withEnv({ PROJECT_STATUS: "live", PROVISIONING_ENABLED: "true" }, () => {
|
||||
assert.equal(isKeyMintingEnabled(), true);
|
||||
assert.equal(keyMintingGateMessage(), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user