feat: add pending project gate

This commit is contained in:
2026-06-19 14:34:35 +07:00
parent cf7688f6ed
commit e4177fac8f
14 changed files with 348 additions and 58 deletions
+6 -2
View File
@@ -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
+38 -8
View File
@@ -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.
+9 -6
View File
@@ -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." };
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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 &lt;your-key&gt;</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 &lt;your-key&gt;</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>
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+16 -2
View File
@@ -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}>
+56
View File
@@ -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.
+67
View File
@@ -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
+59
View File
@@ -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);
});
});