Status: Current orientation. Function code, migrations, tests, and supabase/config.toml remain authoritative.
Supabase provides:
- user sessions and OAuth;
- PostgreSQL persistence and Row Level Security;
- debates and the per-user Current Debate (
debates,current_debates, and theattach_to_debateRPC), plus RPCs, triggers, quotas, subscriptions, sharing, sparring, announcements, and usage accounting; - Edge Functions that protect privileged credentials and integrate with external services;
- local migration replay and pgTAP policy tests.
Do not configure this backend by copying SQL or keys from documentation. Use checked-in migrations, CLI commands, protected environments, and explicit deployment authorization.
supabase/config.toml local and function gateway configuration
supabase/functions/ Deno Edge Functions
supabase/functions/_shared shared auth, validation, prompts, research, and cost logic
supabase/migrations/ ordered database history
supabase/rollbacks/ down scripts for data migrations; never applied by db push
supabase/proposed/ destructive SQL awaiting authorization; never applied by db push
supabase/tests/ pgTAP database and policy tests
ai: authenticated endpoint for generate, attack, brainstorm, factoids, Ask, Crossfire, and sparring. It enforces supported providers and controls, access limits, research bounds, output validation, usage accounting, and sparring integrity. Withstream: trueit streams the model's output as server-sent events and ends with the same body as a JSON request (seeai/stream.ts). A streamed run holds its worker withEdgeRuntime.waitUntiluntil the last event is read. When the request sends aresultKey, the run's result is also written toai_run_resultsbefore it is sent, so the browser can recover it after a dropped stream (ai/run-results.ts).ai-stream: authenticated server-sent events endpoint for Ask. It emits deltas, a final event, or an error and records usage, holding its worker withEdgeRuntime.waitUntiluntil the stream closes.voice: authenticated text-to-speech for the opponent in voice rounds. It accepts one segment of at most 1,000 characters with the tokenaisigned for it, so it speaks only server-written text. It answersvoice_unavailablewithoutELEVENLABS_API_KEY, enforces the per-user daily voice cap throughconsume_daily_quotaunder thevoicekey, calls ElevenLabs (eleven_v4for sparring,eleven_v4_turbofor Crossfire), and records each request inai_requestsasvoice_sparringorvoice_crossfire. Every failure is a JSON error with a code, and the browser then reads the reply with its own voice.
ai/index.ts is the entrypoint and dispatcher; ai/features/ holds one handler per feature, ai/provider.ts the OpenRouter call, JSON repair, and the retry for a reply cut off at its cap (_shared/output-caps.ts holds the caps), ai/request.ts input hygiene, and ai/runtime.ts the environment and service clients. Each feature's prompt lives in _shared/prompts/<feature>.ts, with shared rules in _shared/prompts/common.ts and composition in _shared/prompts/system.ts.
Both endpoints accept an optional debateContext of IDs (debateId, side, sourceIds, outputs). _shared/debate-context-loader.ts loads the matching material with the service role, filtering every query by the caller's user ID and the one debate, and returns 404 for a debate the caller does not own before any quota is reserved. Requests without it still accept the legacy sourceIds field.
Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Sonnet 5.5 is the Pro default and Claude Opus 5.5 runs only when picked; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation.
Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. _shared/provider-routing.ts sends anthropic/* requests with provider: { only: ["anthropic"], allow_fallbacks: false }, so Bedrock, Vertex, Azure and every other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's Shared capacity fallback in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 provider_unavailable and log claude_unavailable. Like any failed run that delivered nothing usable, the request gets back exactly the units it reserved (refundQuotaReservation; see the quota section of docs/architecture.md). Its row is logged with cap_cost_usd 0 only when nothing was paid for: no text written, no finished model call (a research step Claude refused in the same outage does not count), and no OpenRouter charge against the weekly cap. Jev routing and source-ranking rows are logged separately and keep their own cap cost. The Ask page does not resend a provider_unavailable stream failure through the plain endpoint. A Claude response whose usage shows is_byok: false or a cost above zero logs a claude_not_byok warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost.
The browser boundary is ../src/lib/ai/client.ts. Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in _shared.
import-source: authenticated public-URL import with SSRF protection (resolved addresses checked against the IANA special-purpose ranges on every redirect, and the connection made to a checked address, not the hostname, so DNS cannot change in between), one 10-second deadline covering DNS, redirects and the body, content-type and 1 MB size limits, readable-text extraction, a daily quota, and logging.share: authenticated creation of share codes for supported saved content, with a 512 KB payload cap, expiry from 1 to 90 days (30 when omitted), and a daily quota of 50 per user.workspace-invites: part of the hidden Workspace. Authenticated workspace code and email invitation creation, rotation, revocation, and redemption. The handler keeps raw invite values and email addresses out of database calls and uses service-role-only transactional RPCs for limits and membership changes.export-gdoc: authenticated Google Docs export using a short-lived Google access token. It rejects refresh tokens.
demo-rebuttal: the no-login mini round on/ai-debate. Off unlessDEMO_ENABLED=trueandTURNSTILE_SECRET_KEYis set. It accepts only allowed origins, checks a Cloudflare Turnstile token before a session starts, and reserves every model call through the service-role-onlyclaim_demo_round_turnRPC: 3 sessions per IP per UTC day (counted when a session starts, byCF-Connecting-IP; the right-mostX-Forwarded-Forentry is an internal hop that changes per request), 2 turns per session, andDEMO_DAILY_LIMITmodel calls per UTC day across all visitors (500 when unset). The first turn returns a session token signed withSPARRING_INTEGRITY_SECRET(or the service-role fallback) that holds the session's IP hash; the follow-up must send it back and is charged to that hash, so a visitor whose IP changes mid-round can finish it. Rows keep a salted, per-day hash of the IP and counts only; nothing the visitor typed is stored or logged. One Claude Haiku call per turn at low reasoning effort, asked for 90 to 130 words, withmax_tokenssized so the reasoning cannot crowd out the JSON reply. A rebuttal past 170 words, or a reply cut off atmax_tokenswhose moderation flag arrived, is served up to its last full sentence.demo-code-login: bounded pre-session access-code flow with normalized hashing, fail-closed attempt limits, atomic use consumption, server-owned demo-user bindings in Authapp_metadata, and a prohibition on granting administrator status.admin: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions.announcements: authenticated announcement listing and dismissal.redeem-promo: authenticated atomic promo redemption throughredeem_promo_code_atomic. Adiscountcode is claimed there too (it counts againstmax_redemptionsand is single use per user) and must carry the Stripe promotion-code ID it stands for inconfig.stripe_promotion_code.account: authenticated data export and account deletion for the caller only. It never reads a user ID from the request. Deletion cancels Stripe subscriptions first and stops if that fails, then removes Storage objects, clears the user's workspaces, and deletes the auth user. Export and the deletion plan use service-role-only RPCs. See Account export and deletion.
create-checkout: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. A promo code is claimed as the user throughredeem_promo_code_atomicwithp_discount_only, and the session applies only that code'spromo_ID throughdiscounts; Stripe's own promotion-code field is never enabled. Apply20261024120100_discount_promo_claims.sqlbefore deploying it. It records the customer instripe_customersbefore opening a session so that account deletion can stop its billing.create-portal: authenticated Stripe Billing Portal creation with customer ownership checks. Stripe failures answer with a fixedportal_failederror; Stripe's message stays in the log.stripe-webhook: Stripe-signature-validated subscription and invoice event handling. It claims each event inwebhook_eventsbefore running handlers, reads subscription state from Stripe rather than the event payload, marks the claim complete, and lets a retry take over a claim left incomplete for 10 minutes. It needs thewebhook_events.completed_atcolumn, so apply20261016120000_security_preship_hardening.sqlbefore deploying it.
functions/_shared/auth.ts validates bearer tokens, creates user-scoped and service clients, enforces administrator roles, and formats JSON errors.
Other shared modules own:
- prompt construction and source bounds;
- research orchestration and source ranking;
- public URL validation;
- normalization of provider-reported usage and cost;
- control enforcement;
- crossfire response validation;
- audio-tag handling and signed voice tokens for voice rounds (
voice-tags.ts, which the browser also imports, andvoice-token.ts); - the pinned OpenRouter Jev Decisions client, automatic model routing, semantic source decisions, and Crossfire signals;
- announcement input validation.
Move code into _shared only when multiple functions use the same boundary or policy. Feature-specific behavior stays with its handler.
Several entries in config.toml set verify_jwt = false. This does not make those functions public. Their handlers perform their own request validation because they need one of these patterns:
- user bearer validation through shared auth helpers;
- administrator role validation;
- Stripe webhook signature verification;
- pre-session demo-code validation;
- the
/ai-debatemini round, which checks the origin, a Turnstile token and its own rate limits; - short-lived Google token handling.
Do not remove handler authentication because the gateway setting looks permissive. Do not enable gateway verification without checking custom request flows and tests.
The effective gateway behavior for a function without an explicit config entry must be verified before documenting or changing it.
Current handlers mostly define explicit origin allowlists. The exact list differs by endpoint and handler code is authoritative.
Relevant origins include the production site, bounded local development hosts, and the automated staging browser origin.
Do not use wildcard origins to hide a failed browser test. Before protected staging, verify http://127.0.0.1:18080 for demo-code-login, ai, ai-stream, admin, and announcements.
Migration filenames define execution order. Never edit an applied migration to change production history; add a new forward migration.
When creating a table, sequence or function in public, grant only the Data API roles and operations it needs in the same migration, alongside RLS and its policies. Since 20261016120000_security_preship_hardening.sql, tables and sequences that postgres creates in public grant anon and authenticated nothing by default, so a missing grant shows up as permission denied, not as an open table. Functions still get Postgres's global PUBLIC EXECUTE, so every new function needs its own REVOKE ... FROM PUBLIC. supabase/tests/client_grants_test.sql checks both.
Anything destructive (dropping a policy, function, index or column, deleting rows, or a constraint existing rows might break) goes in supabase/proposed/ instead, with a header saying what it removes and what covers it; see proposed/README.md.
Local gate:
npx supabase db start
npm run test:db
npx supabase stop --no-backupRequirements:
- Docker
- Supabase CLI 2.121.0, pinned in
package.json(npm ciinstalls it) - exclusive ownership of the default local ports and containers
The stack begins at port 54321 and is not parallel-safe across worktrees. Coordinate before starting it. Stop the stack before finishing, including after failures when safe.
The database gate proves migration replay and pgTAP assertions locally. It does not prove staging or production state.
20260927170000_debate_context_pins.sql is additive: it creates debate_context_pins, the saved results a student pins per debate, with RLS and a composite key to the owner's debate.
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.
20261022120000_opus_default_to_sonnet.sql moves every saved Claude Opus 5.5 tool setting in user_settings.feature_configs to Claude Sonnet 5.5, because Opus was the Pro default and a saved row cannot tell a default from a pick. It first copies each changed row into private.opus_default_backup and skips users already there, so a second run changes nothing. Apply it only after the frontend that makes Sonnet the Pro default is live; otherwise an account that never saved settings still gets Opus on its first load. supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql restores Opus wherever a tool still holds the Sonnet the migration wrote, and empties the backup. To roll back, copy it into supabase/migrations under a new timestamp. scripts/test-opus-default-migration.sh, part of npm run test:db, runs both twice against seeded rows.
20261023120000_reconcile_admin_table_drift.sql adds what the admin function writes to production's legacy rate_limits and global_prompts tables, which predate 20260204000000_admin_improvements.sql: updated_at (backfilled from created_at only when the column is missing), is_active and created_by on global_prompts, and the unique keys the Set limit and Set prompt upserts infer. A key is skipped with a warning when duplicate rows would block it; the migration deletes nothing and a rerun changes nothing. scripts/test-admin-table-drift-migration.sh, part of npm run test:db, rebuilds the legacy shape in a rolled-back transaction and runs both admin writes against it.
A new function needs more than its own folder. In the same PR:
- Add a
[functions.<name>]block toconfig.tomlwith the intendedverify_jwt. - Add it to the readiness loop in
../.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. - If it calls OpenRouter, Stripe or another paid provider, teach
../e2e/full-stack/fake-openrouter.mjsto answer it. The fake rejects requests it doesn't recognize. - If it reads a new environment variable, add the name to
--allow-envin thetest:edgescript inpackage.json, to Environment and secrets, and toEXPECTED_SECRETSin../scripts/backend-status.mjswhen every deployed project must hold it. - Describe it under Function groups.
Run:
npm run test:edgeThe Deno suite covers shared modules, handler boundaries, control enforcement, research behavior, source validation, cost normalization, and persistence rules. The npm script grants only the permissions required by the test suite.
Run focused tests while changing one function, then run the complete Edge Function gate.
Document names, never values. Depending on deployed features, server-side configuration includes categories such as:
- Supabase URL, anon key, and service-role key;
OPENROUTER_API_KEYfor all model requests, plusOPENROUTER_API_URL, which only the local full-stack gate sets to reach its fake provider (leave it unset everywhere else);LOCAL_GATE_APP_ORIGINS, which only the local gate and verify stack set, so the CORS check accepts the slot's own app origin (http://127.0.0.1:18181for slot 2). It is honoured only for local dev origins; leave it unset everywhere else;ELEVENLABS_API_KEYfor the opponent's voice, and optionallyELEVENLABS_VOICE_IDto replace the default voice; without the key, voice rounds use the browser's voice;- research provider keys;
- Stripe secret, webhook secret, and price configuration, plus
STRIPE_API_URL, which only the local full-stack gate sets so theaccountfunction reaches its fake Stripe (leave it unset everywhere else); - demo-code hashing material;
WORKSPACE_INVITE_SECRETfor invite derivation, keyed hashes, and target-email encryption;WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN, andWORKSPACE_INVITE_BASE_URLfor the operator-supplied email relay, plusWORKSPACE_INVITE_ALLOWED_ORIGINSfor an exact preview origin;- sparring integrity material, which also signs voice tokens and salts the
/ai-debatedemo's IP hashes; DEMO_ENABLED(trueturns the/ai-debatemini round on; anything else, including unset, keeps it off),DEMO_DAILY_LIMIT(its model calls per UTC day, 500 when unset) andTURNSTILE_SECRET_KEY(its bot check; without it the demo stays off);- 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_KEYandEXA_API_KEY, or AI features and web research fail;STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRET, or checkout, the billing portal, the webhook and account deletion fail (stripe-webhookfails at startup without them);ELEVENLABS_API_KEYfor 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. Demo-code login logs a warning when DEMO_CODE_PEPPER is missing and still accepts codes hashed with that fallback. The WORKSPACE_INVITE_* values serve only the hidden Workspace. npm run backend:status -- --project-ref <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.
The invitation handler does not select a production email provider. Its default mail boundary sends { to, inviteUrl, idempotencyKey } to the configured relay. The relay must deduplicate repeated acceptance of the same key, which stays stable across pending-delivery retries. The handler marks delivery only after relay acceptance and suppresses later sends only after that mark commits. Email creation fails before storing an invite when the three required mailer values are absent. The locked local gate alone sets WORKSPACE_INVITE_MAILER_FORMAT=mailpit and captures messages in local Supabase Mailpit; it makes no external mail request.
Public browser configuration in ../src/lib/supabase/env.ts is not a replacement for server authorization.
Use local Supabase for migration replay and pgTAP. Do not point local tests at a remote project.
Protected staging must use a non-production project with matching anon key, current schema and functions, required provider access, a dedicated bounded demo code, correct CORS, and a protected main-only GitHub environment.
Read ../docs/testing.md before running staging.
Migrations, function deployments, secret changes, and data mutation require explicit authorization. Confirm the linked project, exact diff, current baseline, rollback path, and local evidence before any operation.
Read ../docs/operations.md before changing remote state.
For an authorized backend change:
- Confirm the current worktree and commit.
- Resolve the linked Supabase project and environment, and run
npm run backend:statusto see what is pending there. See../docs/operations.md. - Inspect the exact migration or function diff.
- Capture the remote baseline needed for rollback.
- Run focused tests,
npm run test:edge, and database tests when schema or RLS changes. - Verify CORS and handler authentication.
- Apply one scoped operation.
- Exercise the live endpoint or user-scoped database path.
- Record the target, operation, result, and any exception without exposing secrets.
Stop when the target is ambiguous, rollback is unproven, or the operation exceeds authorization.