From 627e03d52db6ae892835aa86678bff4f652a5af9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:39:58 +0000 Subject: [PATCH 1/2] docs: fix stale status docs; check plan status; backend:status secrets and auth URLs - operations.md no longer says staging E2E has been skipped since 2026-09-23 (it runs and currently fails), and warns that Vercel previews use the production Supabase project. - Every plan now carries a **Status:** line. The voice sparring plan said "Plan" after shipping as #84. docs:check now requires the line and rejects a plan listed under Shipped that doesn't say Shipped. - supabase/README.md lists every place a new Edge Function must be registered and the secrets each deployed project needs. - backend:status also lists expected function secrets the project lacks (names only) and prints the Auth site URL and redirect allowlist. - AGENTS.md and plans/README.md: commit a plan before splitting it across agent threads. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- docs/operations.md | 8 +- plans/2026-09-20_131317-debate-workspace.md | 2 + plans/2026-09-21-debate-workspace-no-bs.md | 2 + ...-09-25_074255-current-debate-foundation.md | 2 + ...-09-25_074255-debate-ai-quality-modules.md | 2 + plans/2026-10-01-voice-sparring-crossfire.md | 2 +- plans/README.md | 4 +- scripts/backend-status.mjs | 78 +++++++++++++++++-- scripts/check-docs.mjs | 53 +++++++++++++ src/test/docs/check-docs.test.ts | 37 ++++++++- src/test/scripts/backend-status.test.ts | 26 ++++++- supabase/README.md | 18 +++++ 13 files changed, 218 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e9990d1b..c5bdd0b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ Open only what the task needs: - [docs/testing.md](docs/testing.md): what each gate proves, browser matrices, staging, artifacts, and cleanup. - [docs/operations.md](docs/operations.md): CI, Vercel hosting, staging, deployment, rollback, and live verification. - [supabase/README.md](supabase/README.md): Edge Functions, shared backend modules, migrations, auth boundaries, and required secret names. -- [plans/README.md](plans/README.md): work in progress, the backlog, and shipped plans. Every plan lives in `plans/`; write new plans there. Revalidate every plan against current source before executing it. +- [plans/README.md](plans/README.md): work in progress, the backlog, and shipped plans. Every plan lives in `plans/` and opens with a `**Status:**` line; write new plans there. Commit a plan before splitting it across agent threads, and put its path in each thread's prompt. Revalidate every plan against current source before executing it. - [docs/README.md](docs/README.md): complete documentation index and freshness labels. When commands, ports, workflows, or runtime boundaries change, update the owning focused document in the same change. Do not copy large source trees, schemas, dependency lists, or model catalogs into documentation. diff --git a/docs/operations.md b/docs/operations.md index 85137ce04..e991ce594 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -33,7 +33,7 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou ## Frontend hosting on Vercel -Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests may get preview deployments. The production canary runs after each successful Vercel `Production` deployment. +Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`. Pull requests may get Vercel preview deployments, and those use the production Supabase project unless the in-app switcher picks staging, so treat a preview as production data. For a staging-pinned preview, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment. [`vercel.json`](../vercel.json) owns: @@ -63,14 +63,14 @@ The staging browser suite disables trace, screenshot, and video capture. It crea Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass. -`STAGING_E2E_ENABLED` has been `true` since 2026-09-18, and the job passed through 2026-09-23. Since then it has been skipped because the `Quality` run it waits for fails before any runner starts (see [When GitHub Actions cannot run](#when-github-actions-cannot-run)). Restoring Actions minutes restores the staging job; nothing in the workflow needs changing. +`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- `, not this page. The run uploads no artifact, so the job log is the only evidence. ## Staging sync Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Every backend change goes to staging first, passes the staging browser suite, and only then goes to production. Steps 1 and 2 are read-only. Steps 3 and 4 change staging and need Adam's authorization each time. 1. Confirm the target. `supabase projects list` must show the staging ref under the staging name. Stop if a command, link, or environment variable points at the production ref. -2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. Where that script is not available, run its two read-only queries directly: +2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. It also lists expected function secrets the project lacks (names only) and prints the Auth site URL and redirect allowlist. Where that script is not available, run its two read-only queries directly: ```bash supabase migration list --project-ref brifeyypjauyusmkptkx @@ -94,7 +94,7 @@ The switch only works while the `connect-src` directive in [`vercel.json`](../ve ## Backend drift check -`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. +`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN` or `~/.supabase/access-token`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role. The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports: diff --git a/plans/2026-09-20_131317-debate-workspace.md b/plans/2026-09-20_131317-debate-workspace.md index cfab64de7..e1fbfabf3 100644 --- a/plans/2026-09-20_131317-debate-workspace.md +++ b/plans/2026-09-20_131317-debate-workspace.md @@ -1,5 +1,7 @@ # Debate workspace implementation plan +**Status:** On hold and superseded by [the reduced-scope plan](2026-09-21-debate-workspace-no-bs.md). Collaborative Workspace is hidden in production. Kept as evidence. + > For agentic workers: use `superpowers:executing-plans` or, when delegated execution is authorized, `superpowers:subagent-driven-development`. This is a master plan. Execute one reviewed slice at a time and keep its checkboxes current. Planning does not authorize implementation or deployment. **Goal:** Give a school debate squad one persistent, collaborative place to prepare both sides of a motion, develop speeches, share evidence, use AI, practise, review feedback, and export its work. diff --git a/plans/2026-09-21-debate-workspace-no-bs.md b/plans/2026-09-21-debate-workspace-no-bs.md index 35ad710bd..2638fbe12 100644 --- a/plans/2026-09-21-debate-workspace-no-bs.md +++ b/plans/2026-09-21-debate-workspace-no-bs.md @@ -1,5 +1,7 @@ # Debate Workspace: no-BS replacement plan +**Status:** On hold. Collaborative Workspace is hidden in production and renders only when `VITE_COLLABORATION_URL` is set. + Adam's first working journey: sign in, enter a motion once, land in Intro, invite a teammate by code, assign R1, and write in the same paragraph from two browsers. Switch between A favor and En contra, disconnect one browser, reconnect, reload, and reopen the workspace tomorrow with the same text and last-opened file. Create another workspace with different collaborators and still find the first in the archive. **First-milestone done means** that journey works against real local Auth, PostgreSQL and the collaboration service through a private Tailscale URL. Saved survives a service restart; undo leaves the teammate's work intact; removal blocks subsequent reads and writes. File controls, history/restore, human suggestions and comments work on desktop and mobile. A shell, mocked session, or passing spike is not this deliverable. diff --git a/plans/2026-09-25_074255-current-debate-foundation.md b/plans/2026-09-25_074255-current-debate-foundation.md index b949ddaee..b390a9501 100644 --- a/plans/2026-09-25_074255-current-debate-foundation.md +++ b/plans/2026-09-25_074255-current-debate-foundation.md @@ -1,5 +1,7 @@ # PR 1: Current Debate +**Status:** Shipped in #72 (merged 2026-09-27 as `5ee0b05`). Kept as the record. + ## What Adam wants DebateLab's existing features feel like separate tools. A student enters the same motion repeatedly, manually carries material between pages, and can accidentally use sources from an older topic. Adam wants a lightweight personal debate context now, without rebuilding the postponed collaborative Workspace. diff --git a/plans/2026-09-25_074255-debate-ai-quality-modules.md b/plans/2026-09-25_074255-debate-ai-quality-modules.md index 68408371a..7d788e30a 100644 --- a/plans/2026-09-25_074255-debate-ai-quality-modules.md +++ b/plans/2026-09-25_074255-debate-ai-quality-modules.md @@ -1,5 +1,7 @@ # PR 2: Debate-aware AI and smaller modules +**Status:** Shipped in #75 (merged 2026-09-28 as `f0f2f75`). Kept as the record. + ## What Adam wants Once Current Debate exists, every DebateLab feature should understand the same preparation instead of behaving like a blank tool. The model should receive useful debate context automatically, while the student can see and change what will be sent. The current prompts also need deliberate improvement, and the oversized AI Edge Function needs smaller internal files so future fixes do not require understanding an 88 KB entrypoint. diff --git a/plans/2026-10-01-voice-sparring-crossfire.md b/plans/2026-10-01-voice-sparring-crossfire.md index 5aaffd2fc..7a5725dbe 100644 --- a/plans/2026-10-01-voice-sparring-crossfire.md +++ b/plans/2026-10-01-voice-sparring-crossfire.md @@ -1,6 +1,6 @@ # Voice Sparring and Crossfire -**Status:** Plan. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer. +**Status:** Shipped in #84 (merged 2026-10-02 as `791ccb0`). Kept as the record. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer. ## Why diff --git a/plans/README.md b/plans/README.md index ed37efcb9..4ad4eea3f 100644 --- a/plans/README.md +++ b/plans/README.md @@ -2,7 +2,7 @@ **Status:** Plan index. Plans describe intended work, not current implementation truth. Reconciled against `main` at `791ccb0` on 2026-10-02. -Every plan lives in this folder. Before executing one, recheck its paths, assumptions, product decisions, and verification commands against current source. +Every plan lives in this folder and opens with a `**Status:**` line; a plan listed under Shipped must say Shipped (`npm run docs:check` enforces both). When you split a plan across several agent threads, commit it first and put its path in each thread's prompt: an uncommitted plan exists only in one worktree. Before executing one, recheck its paths, assumptions, product decisions, and verification commands against current source. ## In progress @@ -18,7 +18,7 @@ Nothing is in progress. ## Shipped -These plans are finished. Their files were removed; git history keeps them. +These plans are finished. Most of their files were removed and git history keeps them. The three linked below are kept as the record, and each says `**Status:** Shipped`. - Voice Sparring and Crossfire: #84. Plan kept as the record: [2026-10-01 voice sparring and crossfire](2026-10-01-voice-sparring-crossfire.md). - Debate-aware AI and smaller modules: #75. Plan kept as the record: [2026-09-25 debate AI quality modules](2026-09-25_074255-debate-ai-quality-modules.md). diff --git a/scripts/backend-status.mjs b/scripts/backend-status.mjs index aceca3c5a..c0868f043 100644 --- a/scripts/backend-status.mjs +++ b/scripts/backend-status.mjs @@ -3,9 +3,10 @@ // // Vercel ships the frontend on merge; migrations and Edge Functions are // deployed by hand. This script lists what the current commit has that the -// project does not. It only runs `supabase migration list` and -// `supabase functions list`, and it reads the repository from git HEAD, so -// uncommitted files are ignored. +// project does not. It only runs `supabase migration list`, +// `supabase functions list` and `supabase secrets list` (names only), reads the +// project's Auth URL settings from the Management API, and reads the +// repository from git HEAD, so uncommitted files are ignored. // // Usage: npm run backend:status [-- --project-ref ] [--check] // --project-ref inspect this project instead of the linked one @@ -13,6 +14,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join, posix } from "node:path"; import { pathToFileURL } from "node:url"; @@ -21,6 +23,17 @@ const KNOWN_PROJECTS = { brifeyypjauyusmkptkx: "staging", }; +// Secrets the functions read that Supabase does not inject. DEMO_CODE_PEPPER +// and SPARRING_INTEGRITY_SECRET fall back to the service-role key, and the +// WORKSPACE_INVITE_* values serve the hidden Workspace, so they are not listed. +export const EXPECTED_SECRETS = { + OPENROUTER_API_KEY: "every model request", + EXA_API_KEY: "web research in case generation and Ask", + STRIPE_SECRET_KEY: "checkout, billing portal, webhook and account deletion", + STRIPE_WEBHOOK_SECRET: "stripe-webhook signature check", + ELEVENLABS_API_KEY: "opponent voice; without it voice rounds use the browser voice", +}; + const MIGRATION_FILE = /^(\d+)_.+\.sql$/; const STATIC_IMPORT = /\b(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/g; const BARE_IMPORT = /\bimport\s*["']([^"']+)["']/g; @@ -252,6 +265,55 @@ export function formatReport({ target, local, result, deployedCount, notes }) { return lines.join("\n"); } +export function missingSecrets(names) { + const present = new Set(names); + return Object.entries(EXPECTED_SECRETS) + .filter(([name]) => !present.has(name)) + .map(([name, use]) => `${name}: ${use}`); +} + +function secretsReport(root, targetArgs) { + let names; + try { + const output = supabaseJson(root, ["secrets", "list", ...targetArgs]); + names = (Array.isArray(output) ? output : (output.secrets ?? [])).map((secret) => secret.name); + } catch (error) { + return `Secrets: not checked (${error.message.split("\n")[0]}).`; + } + const missing = missingSecrets(names); + if (missing.length === 0) return "Secrets: every expected function secret is set."; + return ["Secrets: missing expected function secrets (names only):", ...missing.map((line) => ` ${line}`)].join("\n"); +} + +function accessToken() { + const fromEnv = process.env.SUPABASE_ACCESS_TOKEN?.trim(); + if (fromEnv) return fromEnv; + const file = join(homedir(), ".supabase", "access-token"); + return existsSync(file) ? readFileSync(file, "utf8").trim() : null; +} + +/** Site URL and redirect allowlist, which magic links and OAuth redirects must match. */ +export async function authUrlsReport(ref) { + if (!/^[a-z0-9]{20}$/.test(ref)) return "Auth URLs: not checked (unknown project ref)."; + const token = accessToken(); + if (!token) return "Auth URLs: not checked (no SUPABASE_ACCESS_TOKEN or ~/.supabase/access-token)."; + try { + const response = await fetch(`https://api.supabase.com/v1/projects/${ref}/config/auth`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) return `Auth URLs: not checked (Management API answered ${response.status}).`; + const config = await response.json(); + const allowList = String(config.uri_allow_list ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); + return [ + `Auth URLs: site_url ${config.site_url || "(unset)"}`, + " Redirect allowlist:", + ...(allowList.length ? allowList.map((entry) => ` ${entry}`) : [" (empty)"]), + ].join("\n"); + } catch (error) { + return `Auth URLs: not checked (${error.message}).`; + } +} + export function backendStatus({ root, projectRef }) { const local = readCommit(root); const targetArgs = projectRef ? ["--project-ref", projectRef] : []; @@ -280,10 +342,11 @@ export function backendStatus({ root, projectRef }) { const target = label ? `${ref} (${label})` : ref; const result = compareBackend({ local, remoteMigrations, deployedFunctions, changeOf }); - return { result, report: formatReport({ target, local, result, deployedCount: deployedFunctions.length, notes }) }; + const report = formatReport({ target, local, result, deployedCount: deployedFunctions.length, notes }); + return { ref, result, report: `${report}\n\n${secretsReport(root, targetArgs)}` }; } -function main() { +async function main() { let options; try { options = parseArgs(process.argv.slice(2)); @@ -295,8 +358,9 @@ function main() { try { const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim(); - const { result, report } = backendStatus({ root, projectRef: options.projectRef }); + const { ref, result, report } = backendStatus({ root, projectRef: options.projectRef }); console.log(report); + console.log(`\n${await authUrlsReport(ref)}`); if (options.check && result.pendingMigrations.length > 0) process.exit(1); } catch (error) { console.error(error.message); @@ -307,4 +371,4 @@ function main() { } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 3c6379aa3..4010b5979 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -17,6 +17,8 @@ const EXCLUDED_PREFIXES = [ ]; const PLAN_INDEX = "plans/README.md"; +const PLAN_STATUS = /^\*\*Status:\*\*[ \t]*(.*)$/m; +const PLAN_STATUS_LINES = 15; const EXTERNAL_TARGET = /^(?:https?:|mailto:|tel:|data:|\/\/)/i; const RETIRED_CHECKOUT = [ /\b(?:cd|pushd)\s+(?:["'`])?debate-navigator(?=[/\\\s"'`]|$)/i, @@ -107,6 +109,56 @@ function localTarget(rootDir, documentPath, rawTarget) { return { absolutePath }; } +// Plans are excluded from link checks, but each must say where it stands, and +// the index's Shipped section must not list a plan that still calls itself a plan. +async function checkPlans(root, issues) { + let entries; + try { + entries = await readdir(resolve(root, "plans"), { withFileTypes: true }); + } catch { + return; + } + + const statuses = new Map(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") continue; + const content = await readFile(resolve(root, "plans", entry.name), "utf8"); + const head = content.split(/\r?\n/).slice(0, PLAN_STATUS_LINES).join("\n"); + const status = head.match(PLAN_STATUS)?.[1].trim() || null; + statuses.set(entry.name, status); + if (!status) { + issues.push({ file: `plans/${entry.name}`, line: 1, message: `plan has no **Status:** line in its first ${PLAN_STATUS_LINES} lines` }); + } + } + + let index; + try { + index = await readFile(resolve(root, PLAN_INDEX), "utf8"); + } catch { + return; + } + let section = ""; + for (const [lineIndex, line] of index.split(/\r?\n/).entries()) { + const heading = line.match(/^##\s+(.+?)\s*$/); + if (heading) { + section = heading[1].toLowerCase(); + continue; + } + if (section !== "shipped") continue; + for (const match of line.matchAll(INLINE_LINK)) { + const name = normalizeTarget(match[1]).replace(/[?#].*$/, "").replace(/^\.\//, ""); + const status = statuses.get(name); + if (status && !/^shipped\b/i.test(status)) { + issues.push({ + file: PLAN_INDEX, + line: lineIndex + 1, + message: `${name} is listed under Shipped but its status is not "Shipped": ${status}`, + }); + } + } + } +} + export async function checkDocs(rootDir) { const root = resolve(rootDir); const issues = []; @@ -152,6 +204,7 @@ export async function checkDocs(rootDir) { } } + await checkPlans(root, issues); return issues; } diff --git a/src/test/docs/check-docs.test.ts b/src/test/docs/check-docs.test.ts index f59f47f9b..f64b28da5 100644 --- a/src/test/docs/check-docs.test.ts +++ b/src/test/docs/check-docs.test.ts @@ -58,7 +58,7 @@ describe("checkDocs", () => { it("checks the plan index but excludes other plan artifacts", async () => { const root = await createRoot(); await write(root, "plans/README.md", "Run `cd debate-navigator`.\n"); - await write(root, "plans/old.md", "Run `cd debate-navigator`.\n"); + await write(root, "plans/old.md", "**Status:** Shipped.\n\nRun `cd debate-navigator`.\n"); const issues = await (await loadCheckDocs())(root); @@ -155,4 +155,39 @@ describe("checkDocs", () => { await expect((await loadCheckDocs())(root)).resolves.toEqual([]); }); + + it("requires a status line on every plan", async () => { + const root = await createRoot(); + await write(root, "plans/README.md", "# Plans\n"); + await write(root, "plans/new.md", "# New plan\n\nNo status yet.\n"); + await write(root, "plans/dated.md", "# Dated plan\n\n**Status:** Plan. Written 2026-10-01.\n"); + + const issues = await (await loadCheckDocs())(root); + + expect(issues).toEqual([ + { file: "plans/new.md", line: 1, message: "plan has no **Status:** line in its first 15 lines" }, + ]); + }); + + it("rejects a plan listed under Shipped whose status is not shipped", async () => { + const root = await createRoot(); + await write( + root, + "plans/README.md", + "# Plans\n\n## Backlog\n\n- [Next](next.md)\n\n## Shipped\n\n- Done: #1. [Done](done.md)\n- Stale: #2. [Stale](./stale.md)\n", + ); + await write(root, "plans/next.md", "**Status:** Plan.\n"); + await write(root, "plans/done.md", "**Status:** Shipped in #1.\n"); + await write(root, "plans/stale.md", "**Status:** Plan. Written 2026-10-01.\n"); + + const issues = await (await loadCheckDocs())(root); + + expect(issues).toEqual([ + { + file: "plans/README.md", + line: 10, + message: 'stale.md is listed under Shipped but its status is not "Shipped": Plan. Written 2026-10-01.', + }, + ]); + }); }); diff --git a/src/test/scripts/backend-status.test.ts b/src/test/scripts/backend-status.test.ts index eca58bf95..c79a98c3d 100644 --- a/src/test/scripts/backend-status.test.ts +++ b/src/test/scripts/backend-status.test.ts @@ -27,7 +27,15 @@ function runStatus(args: string[], extraEnv: Record = {}) { return spawnSync("node", [SCRIPT, ...args], { cwd: root, encoding: "utf8", - env: { ...env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, ...extraEnv }, + // HOME and an empty token keep the real Management API token out of the test. + env: { + ...env, + CALL_LOG: join(root, "calls.log"), + HOME: root, + PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, + SUPABASE_ACCESS_TOKEN: "", + ...extraEnv, + }, }); } @@ -81,6 +89,10 @@ beforeEach(async () => { { slug: "retired", version: 1, updated_at: 500_000, verify_jwt: true }, ], }; + const secrets = { + message: "", + secrets: ["OPENROUTER_API_KEY", "EXA_API_KEY", "ELEVENLABS_API_KEY"].map((name) => ({ name, value: `digest-of-${name}` })), + }; await mkdir(join(root, "bin")); await writeFile( join(root, "bin/supabase"), @@ -90,6 +102,7 @@ beforeEach(async () => { 'echo "A new version of Supabase CLI is available" >&2', `if [ "$1 $2" = "migration list" ]; then echo '${JSON.stringify(migrations)}'; exit 0; fi`, `if [ "$1 $2" = "functions list" ]; then echo '${JSON.stringify(functions)}'; exit 0; fi`, + `if [ "$1 $2" = "secrets list" ]; then echo '${JSON.stringify(secrets)}'; exit 0; fi`, "exit 99", "", ].join("\n"), @@ -115,23 +128,32 @@ it("reports what the current commit has that the project lacks", async () => { expect(out).toMatch(/verify_jwt differs from supabase\/config.toml:\n {4}beta: deployed false, config true\n/); expect(out).toMatch(/Deployed but not on this commit:\n {4}retired\n/); expect(out).toContain("4 pending items."); + expect(out).toMatch(/Secrets: missing expected function secrets \(names only\):\n {4}STRIPE_SECRET_KEY: .+\n {4}STRIPE_WEBHOOK_SECRET: .+\n\n/); + expect(out).not.toContain("digest-of-"); + expect(out).toContain("Auth URLs: not checked (unknown project ref)."); }); it("uses only read-only list commands and targets the linked project by default", async () => { runStatus([]); const calls = (await readFile(join(root, "calls.log"), "utf8")).trim().split("\n"); - expect(calls).toEqual(["migration list --linked --output-format json", "functions list --output-format json"]); + expect(calls).toEqual([ + "migration list --linked --output-format json", + "functions list --output-format json", + "secrets list --output-format json", + ]); }); it("passes --project-ref to both commands and names known projects", async () => { const result = runStatus(["--project-ref", STAGING_REF]); expect(result.stdout).toContain(`Backend status: ${STAGING_REF} (staging) compared with commit`); + expect(result.stdout).toContain("Auth URLs: not checked (no SUPABASE_ACCESS_TOKEN or ~/.supabase/access-token)."); const calls = (await readFile(join(root, "calls.log"), "utf8")).trim().split("\n"); expect(calls).toEqual([ `migration list --project-ref ${STAGING_REF} --output-format json`, `functions list --project-ref ${STAGING_REF} --output-format json`, + `secrets list --project-ref ${STAGING_REF} --output-format json`, ]); }); diff --git a/supabase/README.md b/supabase/README.md index 4f42a0f42..16ec20ce6 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -128,6 +128,16 @@ The database gate proves migration replay and pgTAP assertions locally. It does `20260926090100_legacy_debate_cleanup.sql` defines `private.purge_legacy_debate_material`, which deletes legacy results, chats, flowcharts, sparring rounds, and sources that belong to no debate and had no activity in the 30 days before `20260926090000_current_debates.sql` recorded its rollout time in `private.current_debate_rollout`. The migration does not call it, so applying it deletes nothing; legacy rows stay in the Unsorted view. Running the purge on a remote project is a separate, destructive step that needs explicit authorization for that environment. Work created after the rollout is never touched, however late it runs. +## Adding an Edge Function + +A new function needs more than its own folder. In the same PR: + +1. Add a `[functions.]` block to [`config.toml`](config.toml) with the intended `verify_jwt`. +2. Add it to the readiness loop in [`../.superset/db-gate.sh`](../.superset/db-gate.sh) (`for function_name in ...`) when the full-stack gate calls it, and to the matching loop in [`../.claude/skills/verify-debatelab/bin/verify.sh`](../.claude/skills/verify-debatelab/bin/verify.sh). +3. If it calls OpenRouter, Stripe or another paid provider, teach [`../e2e/full-stack/fake-openrouter.mjs`](../e2e/full-stack/fake-openrouter.mjs) to answer it. The fake rejects requests it doesn't recognize. +4. If it reads a new environment variable, add the name to `--allow-env` in the `test:edge` script in `package.json`, to [Environment and secrets](#environment-and-secrets), and to `EXPECTED_SECRETS` in [`../scripts/backend-status.mjs`](../scripts/backend-status.mjs) when every deployed project must hold it. +5. Describe it under [Function groups](#function-groups). + ## Edge Function tests Run: @@ -156,6 +166,14 @@ Document names, never values. Depending on deployed features, server-side config - Google export configuration; - allowed origins and environment-specific application URLs. +Supabase injects `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY`. Every deployed project, staging included, also needs: + +- `OPENROUTER_API_KEY` and `EXA_API_KEY`, or AI features and web research fail; +- `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, or checkout, the billing portal, the webhook and account deletion fail (`stripe-webhook` fails at startup without them); +- `ELEVENLABS_API_KEY` for the opponent's voice, which otherwise falls back to the browser voice. + +`DEMO_CODE_PEPPER` and `SPARRING_INTEGRITY_SECRET` fall back to a value derived from the service-role key. The `WORKSPACE_INVITE_*` values serve only the hidden Workspace. `npm run backend:status -- --project-ref ` lists which expected names a project lacks, without printing values. + Use `supabase secrets` or protected deployment configuration for server values. Never place server secrets in `VITE_*`, source files, docs, screenshots, traces, logs, or comments. Jev uses the same `OPENROUTER_API_KEY` as every other model request; there is no separate TypeSafe credential. `npm run smoke:jev` exercises one fixed, sanitized typed decision against the live Decisions endpoint and incurs a negligible provider charge. From 44b0d33107709d851bf7d651fb1890e594f0f358 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:51:24 +0000 Subject: [PATCH 2/2] fix(backend-status): read the CLI token from SUPABASE_HOME; explain the keyring case Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/operations.md | 2 +- scripts/backend-status.mjs | 6 ++++-- src/test/scripts/backend-status.test.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index e991ce594..9203a6cfa 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -94,7 +94,7 @@ The switch only works while the `connect-src` directive in [`vercel.json`](../ve ## Backend drift check -`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN` or `~/.supabase/access-token`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role. +`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN`, or the `access-token` file in `SUPABASE_HOME`, default `~/.supabase`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role. The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports: diff --git a/scripts/backend-status.mjs b/scripts/backend-status.mjs index c0868f043..83dc6bf25 100644 --- a/scripts/backend-status.mjs +++ b/scripts/backend-status.mjs @@ -285,10 +285,12 @@ function secretsReport(root, targetArgs) { return ["Secrets: missing expected function secrets (names only):", ...missing.map((line) => ` ${line}`)].join("\n"); } +// The CLI's file-backed token. A token kept only in the OS keyring can't be +// read here; set SUPABASE_ACCESS_TOKEN for this command instead. function accessToken() { const fromEnv = process.env.SUPABASE_ACCESS_TOKEN?.trim(); if (fromEnv) return fromEnv; - const file = join(homedir(), ".supabase", "access-token"); + const file = join(process.env.SUPABASE_HOME?.trim() || join(homedir(), ".supabase"), "access-token"); return existsSync(file) ? readFileSync(file, "utf8").trim() : null; } @@ -296,7 +298,7 @@ function accessToken() { export async function authUrlsReport(ref) { if (!/^[a-z0-9]{20}$/.test(ref)) return "Auth URLs: not checked (unknown project ref)."; const token = accessToken(); - if (!token) return "Auth URLs: not checked (no SUPABASE_ACCESS_TOKEN or ~/.supabase/access-token)."; + if (!token) return "Auth URLs: not checked (no access token file; if the CLI keeps it in the OS keyring, set SUPABASE_ACCESS_TOKEN)."; try { const response = await fetch(`https://api.supabase.com/v1/projects/${ref}/config/auth`, { headers: { Authorization: `Bearer ${token}` }, diff --git a/src/test/scripts/backend-status.test.ts b/src/test/scripts/backend-status.test.ts index c79a98c3d..79dff5385 100644 --- a/src/test/scripts/backend-status.test.ts +++ b/src/test/scripts/backend-status.test.ts @@ -34,6 +34,7 @@ function runStatus(args: string[], extraEnv: Record = {}) { HOME: root, PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, SUPABASE_ACCESS_TOKEN: "", + SUPABASE_HOME: "", ...extraEnv, }, }); @@ -148,7 +149,7 @@ it("passes --project-ref to both commands and names known projects", async () => const result = runStatus(["--project-ref", STAGING_REF]); expect(result.stdout).toContain(`Backend status: ${STAGING_REF} (staging) compared with commit`); - expect(result.stdout).toContain("Auth URLs: not checked (no SUPABASE_ACCESS_TOKEN or ~/.supabase/access-token)."); + expect(result.stdout).toContain("Auth URLs: not checked (no access token file; if the CLI keeps it in the OS keyring, set SUPABASE_ACCESS_TOKEN)."); const calls = (await readFile(join(root, "calls.log"), "utf8")).trim().split("\n"); expect(calls).toEqual([ `migration list --project-ref ${STAGING_REF} --output-format json`,