diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index a48364bfc..4531f3694 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -10,13 +10,20 @@ permissions: # Manual dispatch queries workflow runs for the selected main commit. actions: read contents: read + # Waits for the staging-sync commit status set by the VPS sync. + statuses: read concurrency: group: protected-staging-e2e-main cancel-in-progress: false jobs: - staging-e2e: + # Staging is one shared backend that the VPS sync (docs/staging.md) moves to + # each new main commit and marks with a staging-sync commit status. Test a + # commit only once staging runs it, and skip a commit that main has already + # moved past: staging runs the newer backend, and the newer commit's run + # tests it. + staging-sync-gate: if: >- ( (github.event_name == 'workflow_run' && @@ -26,8 +33,63 @@ jobs: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ) && vars.STAGING_E2E_ENABLED == 'true' runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + current: ${{ steps.wait.outputs.current }} + steps: + - name: Wait for staging to sync this commit + id: wait + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + shell: bash + run: | + for _ in $(seq 1 40); do + main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + if [ "$main" != "$SHA" ]; then + echo "::notice::main moved on to ${main}; staging runs that backend, and its own run tests it. Skipping ${SHA}." + echo "current=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses" \ + --jq '[.[] | select(.context == "staging-sync")][0].state // "missing"')" + case "$state" in + success) + echo "Staging is synced with ${SHA}." + echo "current=true" >> "$GITHUB_OUTPUT" + exit 0 ;; + failure|error) + echo "::error::Staging sync failed for ${SHA}. See the open 'Staging is out of sync with main' issue." + exit 1 ;; + esac + sleep 30 + done + echo "::error::No staging-sync status for ${SHA} after 20 minutes. Is debatelab-staging-sync.timer running on the VPS? See docs/staging.md." + exit 1 + + staging-e2e: + needs: staging-sync-gate + if: needs.staging-sync-gate.outputs.current == 'true' + runs-on: ubuntu-latest + # Bounds how long the VPS sync holds off for this run (ops/staging-sync/run.sh). + timeout-minutes: 30 environment: staging steps: + # "Re-run failed jobs" skips the gate, so check again on every attempt. + - name: Confirm staging still runs this commit + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + shell: bash + run: | + main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses" \ + --jq '[.[] | select(.context == "staging-sync")][0].state // "missing"')" + if [ "$main" != "$SHA" ] || [ "$state" != "success" ]; then + echo "::error::Staging no longer runs ${SHA} (main is ${main}, staging-sync is ${state}). Rerun the newest main commit's run instead." + exit 1 + fi + - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/staging-preview-note.yml b/.github/workflows/staging-preview-note.yml new file mode 100644 index 000000000..1adf68fba --- /dev/null +++ b/.github/workflows/staging-preview-note.yml @@ -0,0 +1,37 @@ +name: Staging preview note + +# Staging runs main's backend (docs/staging.md). A PR that changes supabase/ +# gets a comment saying its staging preview still uses main's migrations and +# Edge Functions until it merges. + +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - "supabase/migrations/**" + - "supabase/functions/**" + +permissions: + pull-requests: write + +jobs: + note: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Comment once + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + shell: bash + run: | + marker="" + # Read every comment first: grep -q exiting early would SIGPIPE gh, and + # under pipefail that reads as "no marker" and posts a duplicate. + bodies="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq '.[].body')" + if grep -qF "$marker" <<< "$bodies"; then + exit 0 + fi + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$marker + This PR changes \`supabase/\`. Its **debatelab-staging** preview talks to staging, which runs main's migrations and Edge Functions, so this PR's backend changes are not there yet. The VPS staging sync applies them within about 5 minutes of the merge. To try them before merging, use the \`verify-debatelab\` skill against a local stack. See docs/staging.md." diff --git a/AGENTS.md b/AGENTS.md index 37e1c1a71..1feaf0843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ The local Supabase stack still uses shared default ports and container names. In ## Deployment -The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed by that merge; they are separate authorized operations. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. +The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed to production by that merge; production deploys are separate authorized operations. Staging syncs itself from `main` within about 5 minutes through a VPS timer; [docs/staging.md](docs/staging.md) is the one place for staging access, seed data, and the sync. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. ## Normal ports diff --git a/docs/README.md b/docs/README.md index ecb0de9c4..5786cf79e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ Start with the smallest document that answers the task instead of loading every ## Operate or deploy - [Operations guide](operations.md), current. Open before changing CI, Vercel hosting, staging, CORS, migrations, Edge Function deployment, rollback, or production state. +- [Staging](staging.md), current. How to get into staging, the seeded accounts, and the VPS timer that syncs it with `main`. - [`.github/workflows/`](../.github/workflows/) is the CI source of truth. ## Plans diff --git a/docs/operations.md b/docs/operations.md index 2eac77290..143711fbc 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -66,28 +66,15 @@ 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. 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. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`. +`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. 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. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`. No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, because the `Quality` run it waits for never started. Once Actions came back, it failed on every `main` push through 2026-10-02. The cause was the spec, not staging: since the Current Debate change (#72), Library disables **Add Source** until a debate is current, and the spec never started one. The spec now starts a uniquely named debate and deletes it afterwards. The suite drives the real UI, so a change to a flow it covers can break it with no backend drift; run it locally before blaming staging. ## 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. +Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Staging syncs itself: a timer on the VPS applies migrations and deploys changed Edge Functions to staging within about 5 minutes of every commit on `main`. It sets the `staging-sync` commit status, and on failure it opens the **Staging is out of sync with main** issue. No per-run authorization is needed for staging. [staging.md](staging.md) covers the timer, its credential, the seed, and how to get in. -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. 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 - supabase functions list --project-ref brifeyypjauyusmkptkx - ``` - - A migration with a local version and no remote version is pending. A function whose folder changed (`git log -- supabase/functions/ supabase/functions/_shared`) after its remote `updated_at` is stale. Save both lists as the rollback baseline. -3. Apply migrations. Run `supabase db push --project-ref brifeyypjauyusmkptkx --dry-run` and check that it lists exactly the pending files from step 2. Then run it without `--dry-run`. Never use `--include-all` to paper over divergent history. -4. Deploy functions, one at a time, only the ones step 2 marked stale: `supabase functions deploy --project-ref brifeyypjauyusmkptkx --use-api`. [`supabase/config.toml`](../supabase/config.toml) supplies each function's JWT setting. Never use `--prune`. -5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings and sign in there with a staging demo code. Google and email sign-in on staging work only if the staging Auth redirect URLs include the frontend origin. Since 2026-10-02 they include tailnet previews (`https://codex-remote.tailab0ab3.ts.net:91*/**`); see [development.md](development.md#preview-on-the-tailnet). - -Production then follows [Supabase changes](#supabase-changes) with the same list of migrations and functions. +Before a production backend deploy, check that the commit's `staging-sync` status is green and that `Protected staging E2E` passed for it. Then follow [Supabase changes](#supabase-changes) with the list of migrations and functions that `npm run backend:status` reports for production. ## Switching the frontend to staging diff --git a/docs/staging.md b/docs/staging.md new file mode 100644 index 000000000..adae12056 --- /dev/null +++ b/docs/staging.md @@ -0,0 +1,80 @@ +# Staging + +**Current.** The one place that says how to reach staging, what is on it, and how it stays in sync with `main`. + +Staging is the Supabase project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Staging has its own users, data, and secrets. Nothing on staging can read or write production. + +## Get in + +**On your phone or laptop, for a PR.** Every PR gets a `debatelab-staging` Vercel preview; its link is in the Vercel comment on the PR. That build is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. + +**Sign in.** Two ways: + +- Adam: **Continue with Google** with your own account. The seed made that account an admin on Pro with three debates. +- Agents and tests: a staging demo code. The seeded Pro debater's code is `STAGING_SEED_PRO_CODE` in `~/.config/debatelab/staging-access.env` on the VPS (mode 600). Never paste it into GitHub, docs, or screenshots. + +A production account, demo code, or email code does not work on staging, and a staging one does not work on production. + +**Without a PR.** `npm run dev:preview` runs your working tree pinned to staging on the tailnet ([development.md](development.md#preview-on-the-tailnet)). Use it for uncommitted changes. + +## What is on it + +`node scripts/staging/seed.mjs` creates invented accounts and content. Every row has a fixed id, and a rerun restores the seed without duplicating it. The sync reruns the seed after each new commit on `main`. + +| Account | Plan | Content | +| --- | --- | --- | +| `seed-admin@staging.debatelab.test` | Pro, admin | none; creates the demo code | +| `seed-pro@staging.debatelab.test` | Pro | 3 debates, demo code `STAGING_SEED_PRO_CODE` | +| `seed-trial@staging.debatelab.test` | Trial, 5 days left at seeding | 1 debate | +| `seed-expired@staging.debatelab.test` | Expired trial | 1 debate | +| `STAGING_OWNER_EMAIL` (Adam's Google address) | Pro, admin | 3 debates | + +The debates cover private cars in city centres, platform liability for misinformation, and standardised testing. Each has two or three sources and two or three saved results. Between them they cover cases, attacks, brainstorms, and factoids, and the liability debate also has an Ask chat. Seeded ids start with `5eed`. The fixtures live in [`scripts/staging/fixtures.mjs`](../scripts/staging/fixtures.mjs); all of the text is made up. + +A demo code turns its account into lifetime Pro when used, so the trial and expired accounts have none. To try those plans: + +- Adam, on any device: `node scripts/staging/seed.mjs --owner-tier trial` (or `expired`) on the VPS, then reload. The next seed run, at the latest the next merge to `main`, puts the account back on Pro, or run `--owner-tier pro`. +- Agents: `node scripts/staging/seed.mjs --session trial > "$tmp/session.json"` prints a session for any seeded account (`admin`, `pro`, `trial`, `expired`, `owner`) without sending mail. Put it in the `debate-auth-staging` localStorage key before the app loads, as [`e2e/staging/auth.setup.ts`](../e2e/staging/auth.setup.ts) does. It holds live tokens: keep it in a mode-600 file and delete it afterwards. + +Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing portal, `stripe-webhook`, and the Stripe part of account deletion fail on staging, and voice rounds fall back to the browser voice. AI requests and web research use staging's own OpenRouter and Exa keys. + +## How it stays in sync + +A user systemd timer on Adam's VPS, `debatelab-staging-sync.timer`, runs [`ops/staging-sync/run.sh`](../ops/staging-sync/run.sh) every 5 minutes. When `origin/main` has a commit it has not synced, it: + +1. checks that commit out in a dedicated clone at `~/ops/debatelab-staging-sync/repo` (it never runs `npm install`); +2. runs [`scripts/staging-sync.mjs`](../scripts/staging-sync.mjs), which refuses unless the Supabase CLI sees `brifeyypjauyusmkptkx` named `debate-trainer-staging`, passes `--project-ref brifeyypjauyusmkptkx` to every CLI call, applies pending migrations, deploys new or changed Edge Functions except the hidden `workspace-invites`, and then checks that nothing is left pending. A function counts as changed when git has changed it since its last deploy, or when the source staging runs (from `supabase functions download`) differs byte for byte from the commit. The second check catches an older checkout deployed by hand; +3. reruns the seed; +4. sets the `staging-sync` commit status on that commit, and opens or comments on the GitHub issue **Staging is out of sync with main** when anything fails. A later successful sync closes the issue. + +An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. It never deletes a function: one that is deployed on staging but gone from the commit fails the sync until someone runs `supabase functions delete --project-ref brifeyypjauyusmkptkx`. `workspace-invites` is the exception, because it is hidden and never deployed. + +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds any sync while a browser job is running. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. + +PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. + +### The credential and its blast radius + +The timer uses the Supabase CLI login already on the VPS (`~/.supabase/access-token`). It is a classic personal access token for Adam's account, so it reaches production too. The scripts' ref checks keep the sync itself on staging, but they are not a security boundary. Anyone who can merge to `main` can change those scripts, and the timer will run the new code with that token within 5 minutes. A malicious or broken merge could read or change production, including its database and secrets. Today that is the same trust as the other agents on the VPS that use this token, and only Adam merges to `main`. + +To shrink it, replace the token with a Supabase scoped token limited to `debate-trainer-staging` (Database and Edge Functions read-write) and pass it to the service as `SUPABASE_ACCESS_TOKEN`. Then a bad merge can touch staging only. + +### Operate it + +```bash +systemctl --user list-timers debatelab-staging-sync.timer # next run +journalctl --user -u debatelab-staging-sync -n 100 --no-pager # last runs +systemctl --user start debatelab-staging-sync.service # sync now +node scripts/staging-sync.mjs --dry-run # what a sync would do, from your checkout +npm run backend:status -- --project-ref brifeyypjauyusmkptkx # read-only drift report +bash ops/staging-sync/install.sh # after changing run.sh or the units +``` + +The unit's `PATH` names `~/.nvm/versions/node/v24.20.0/bin`, where `node` and `supabase` live. If nvm drops that version, the service fails before it can report, so update the unit and rerun `install.sh`. State lives in `~/ops/debatelab-staging-sync/state/` (`last-ok`, `last-failed`); delete a file to force a rerun. + +## Rules + +- Production is never a fallback. When staging access fails, stop. +- Staging browser tests create uniquely named data and delete only what they created ([testing.md](testing.md#protected-staging)). +- To run the staging browser suite locally, use the seeded Pro code as `STAGING_DEMO_CODE`, and rerun the seed afterwards to restore that account's Current Debate. The CI code exists only as a GitHub secret. The copy in `~/.config/debatelab/staging-e2e.env` expired on 2026-09-10. +- `VERCEL_STAGING_BYPASS` (GitHub `staging` environment, and the VPS access file) lets CI and agents open protected `debatelab-staging` previews with the `x-vercel-protection-bypass` header. Treat it like the demo codes. diff --git a/docs/testing.md b/docs/testing.md index 8a99be1fb..93e73f983 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -272,6 +272,8 @@ This is the Safari-engine smoke gate. State explicitly when it did not run. ## Protected staging +To get into staging by hand, or to see what is seeded there, read [staging.md](staging.md). The suite tests whatever backend staging runs, and in CI it waits for the `staging-sync` status on its commit first. + Staging requires all three protected values: - `STAGING_SUPABASE_URL` diff --git a/ops/staging-sync/debatelab-staging-sync.service b/ops/staging-sync/debatelab-staging-sync.service new file mode 100644 index 000000000..0eb0b8b2b --- /dev/null +++ b/ops/staging-sync/debatelab-staging-sync.service @@ -0,0 +1,14 @@ +[Unit] +Description=Sync DebateLab staging Supabase with main +Documentation=https://github.com/Programcode/DebateLab/blob/main/docs/staging.md +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=%h/ops/debatelab-staging-sync/run.sh +Environment=PATH=%h/.nvm/versions/node/v24.20.0/bin:/usr/local/bin:/usr/bin:/bin +UMask=0077 +TimeoutStartSec=30min +NoNewPrivileges=yes +PrivateTmp=yes diff --git a/ops/staging-sync/debatelab-staging-sync.timer b/ops/staging-sync/debatelab-staging-sync.timer new file mode 100644 index 000000000..21897a8f4 --- /dev/null +++ b/ops/staging-sync/debatelab-staging-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check DebateLab main for staging sync every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ops/staging-sync/install.sh b/ops/staging-sync/install.sh new file mode 100755 index 000000000..32f88dab9 --- /dev/null +++ b/ops/staging-sync/install.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Installs or updates the staging sync timer on Adam's VPS. Rerun after +# changing run.sh or the unit files. The sync scripts themselves come from main. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +base="$HOME/ops/debatelab-staging-sync" +units="$HOME/.config/systemd/user" +mkdir -p "$base" "$units" +install -m 755 "$here/run.sh" "$base/run.sh" +install -m 644 "$here/debatelab-staging-sync.service" "$here/debatelab-staging-sync.timer" "$units/" +systemctl --user daemon-reload +systemctl --user enable --now debatelab-staging-sync.timer +systemctl --user list-timers debatelab-staging-sync.timer --no-pager diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh new file mode 100755 index 000000000..fa1d12958 --- /dev/null +++ b/ops/staging-sync/run.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Runs on Adam's VPS from debatelab-staging-sync.timer. Keeps a dedicated clone +# of main and syncs staging whenever main moves, plus an hourly drift check. +# Never runs npm install: the sync scripts have no dependencies, so merged code +# only reaches the Supabase CLI as files to upload, never as packages to run. +set -euo pipefail + +BASE="${DEBATELAB_STAGING_SYNC_HOME:-$HOME/ops/debatelab-staging-sync}" +REPO="$BASE/repo" +STATE="$BASE/state" +mkdir -p "$STATE" + +exec 9>"$BASE/lock" +flock -n 9 || { echo "Another sync is running."; exit 0; } + +if [ ! -d "$REPO/.git" ]; then + git clone --quiet https://github.com/Programcode/DebateLab.git "$REPO" +fi +git -C "$REPO" fetch --quiet origin main +sha="$(git -C "$REPO" rev-parse origin/main)" + +# Skip when this commit already synced within the hour, or failed within the hour. +for marker in ok failed; do + if [ "$(cat "$STATE/last-$marker" 2>/dev/null)" = "$sha" ] && [ -n "$(find "$STATE/last-$marker" -mmin -60)" ]; then + exit 0 + fi +done + +# Don't change the backend while a browser job is testing it. Only a running +# browser job counts: a queued run, or one still waiting in its gate for this +# very sync, isn't using staging yet, and an older run's gate skips once main +# moves on. The browser job times out after 30 minutes. +fail_query() { + echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 + exit 1 +} +busy=0 +runs="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status in_progress --limit 100 \ + --json databaseId --jq '.[].databaseId')" || fail_query +for id in $runs; do + testing="$(gh run view "$id" --repo Programcode/DebateLab --json jobs \ + --jq '[.jobs[] | select(.name == "staging-e2e" and .status == "in_progress")] | length')" || fail_query + [ "$testing" = "0" ] || busy=1 +done +if [ "$busy" != "0" ]; then + echo "Protected staging E2E is testing; syncing $sha after it finishes." + exit 0 +fi + +git -C "$REPO" checkout --quiet --force --detach "$sha" +git -C "$REPO" clean -fdq + +if [ ! -f "$REPO/scripts/staging-sync.mjs" ]; then + echo "main ($sha) has no scripts/staging-sync.mjs yet; nothing to do." + exit 0 +fi + +seed=() +[ "$(cat "$STATE/last-ok" 2>/dev/null)" = "$sha" ] || seed=(--seed) + +if node "$REPO/scripts/staging-sync.mjs" --root "$REPO" --report "${seed[@]}"; then + echo "$sha" > "$STATE/last-ok" + rm -f "$STATE/last-failed" +else + status=$? + # 3: a browser job started while syncing; nothing changed, retry next tick. + [ "$status" -eq 3 ] && exit 0 + echo "$sha" > "$STATE/last-failed" + exit "$status" +fi diff --git a/plans/2026-10-03-staging-loop.md b/plans/2026-10-03-staging-loop.md new file mode 100644 index 000000000..c1560e5ce --- /dev/null +++ b/plans/2026-10-03-staging-loop.md @@ -0,0 +1,72 @@ +# Staging that tracks main: audit and plan + +**Status:** Plan, in progress. Written 2026-10-03 against `main` at `34d1573`. The sync, seed, and docs ship in the PR that adds this file. Previews ship in #95 and the staging spec fix in #100. The follow-ups at the end are open. + +Goal (Adam, 2026-10-02): when an agent opens a PR, Adam can try the change within seconds on his phone, against a backend that matches, with realistic data, and nothing there can touch production. + +## Audit of staging on 2026-10-02 + +Project `debate-trainer-staging` (`brifeyypjauyusmkptkx`), Free plan. + +**Backend drift.** Staging lacked 1 migration and 11 Edge Function deploys from `main`. `workspace-invites` was also undeployed and stays that way, because the Workspace is hidden and production doesn't run it. The cause was process, not tooling: `docs/operations.md` required Adam's authorization for every staging migration or deploy, so nobody did them. The backlog was cleared on 2026-10-02, and `npm run backend:status -- --project-ref brifeyypjauyusmkptkx` showed nothing pending at `34d1573`. + +**Secrets (names only).** Present: everything AI and research need. Missing: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `ELEVENLABS_API_KEY`. + +**Auth.** The Site URL was `http://localhost:8080`. The redirect allowlist held localhost, the production domain, and tailnet previews, but no hosted staging frontend. The #95 thread has since set the Site URL to `https://debatelab-staging.vercel.app` and added the `debatelab-staging` preview and production hosts. Google is enabled on staging. + +**How the frontend reached it.** There were two ways: +- An admin-only localStorage switch in Settings (#81), which clears local state across tabs on every flip. +- `npm run dev:preview` tailnet previews (#92). + +Vercel PR previews used production, and production's Auth allowlist had no `vercel.app` host, so signing in on a preview landed on the live site. + +**Data.** Nothing realistic. The newest source not written by a test was from 2026-05-27. + +**What the docs promised versus what held.** +- `operations.md` said previews use production unless toggled. True, and that was the problem. +- It said every backend change goes to staging first and passes the staging suite before production. Not true in practice: staging lagged, so production was often ahead of it. +- It said `Protected staging E2E` runs after each successful main `Quality`. It ran, but it had failed on every real run since 2026-09-23. #72 disabled **Add Source** until a Current Debate exists, and the spec never starts one. #100 fixes the spec. The lagging backend was real but did not cause these failures. +- `testing.md`'s staging contract (unique data, deletes only its own, no captures) holds. + +## Decisions + +Adam answered these on 2026-10-02: + +1. **The sync credential stays on the VPS.** A systemd timer uses the Supabase CLI token already there. Nothing new leaves the box. The cost is blast radius: that token reaches production, and the timer runs whatever `main` contains. See [docs/staging.md](../docs/staging.md#the-credential-and-its-blast-radius). +2. **Previews stay behind Vercel login,** with a Protection Bypass for Automation secret for CI and agents (`VERCEL_STAGING_BYPASS`, created 2026-10-03 on the `debatelab-staging` project). +3. **Staging is shared and equals `main`.** Per-PR database branches need the Pro plan, which is a billing change, so they are not adopted. A PR that changes `supabase/` gets a comment saying its backend reaches staging only on merge. +4. **Adam signs in with Google; agents use demo codes.** + +## What ships + +In this PR: +- `scripts/staging-sync.mjs`: refuses any ref but staging, applies migrations, deploys changed functions, rechecks, and reports. It sets a `staging-sync` commit status and manages a drift issue. +- `ops/staging-sync/`: the timer, the service, `run.sh` (a dedicated clone of `main`, no `npm install`), and `install.sh`. It has been installed on the VPS since 2026-10-03. +- `scripts/staging/seed.mjs` and `fixtures.mjs`: four seeded accounts on Pro, trial, and expired plans, plus Adam's account. Three invented debates with sources and saved results. Fixed ids, idempotent. +- `seed.mjs --session ` mints a session for agents to sign in as any seeded account, because a demo code turns its account into Pro. `--owner-tier` puts Adam's account on trial or expired until the next seed. +- `Protected staging E2E` gains a gate job that waits for `staging-sync` success on its commit and skips commits `main` has already moved past, so it never tests a stale or newer backend. +- `staging-preview-note.yml`: the PR comment from decision 3. +- `docs/staging.md`: the one way in. `operations.md` and `testing.md` point to it. + +In #95: `VITE_SUPABASE_ENV` pins a build to a project, the `debatelab-staging` Vercel project builds every PR pinned to staging, and Edge Function CORS accepts its preview hosts on staging. + +In #100: the staging spec starts and cleans up its own Current Debate. + +## What to delete, not polish + +- **The manual staging sync procedure** in `operations.md`. The timer replaces it, so this PR deletes it. +- **The admin localStorage switch in Settings and `e2e/staging-switch.spec.ts`.** Once #95 merges, `https://debatelab-staging.vercel.app` and per-PR staging previews cover every use. The switch shares persisted stores between two projects and needs cross-tab clearing to stay safe, which is a lot of code for a convenience. Remove it in a follow-up after #95 merges (both touch `src/lib/supabase/env.ts`). Keep the staging strip that pinned builds show. +- **Stripe and ElevenLabs on staging.** Not worth provisioning. Billing is proven in Stripe test mode and voice falls back to the browser voice. If account deletion ever needs proof on staging, it needs a Stripe test-mode key, and that is a new secret Adam must approve. + +## Verification plan + +1. Before merge: the sync's dry run against staging, the production-ref refusal (unit test plus a real run with `SUPABASE_PROJECT_ID` set to production), and two seed runs with identical seeded row counts. +2. On merge of this PR: the timer picks up the merge commit within 5 minutes, sets `staging-sync` to success, and reseeds. +3. On merge of #95: its `cors.ts` change reaches staging through the timer with nobody running a deploy. This is the proof for "the next merge syncs staging by itself". After that, open a `debatelab-staging` preview on a phone-sized viewport and sign in with the seeded Pro code. +4. On merge of #100: `Protected staging E2E` passes against the synced backend. + +## Follow-ups + +- Replace the classic token with a staging-scoped Supabase token (Adam creates it; the timer reads it as `SUPABASE_ACCESS_TOKEN`). +- Delete the Settings staging switch after #95. +- Make the unit's Node path survive nvm upgrades. diff --git a/plans/README.md b/plans/README.md index 4c1068380..5c0b5a957 100644 --- a/plans/README.md +++ b/plans/README.md @@ -6,7 +6,7 @@ Every plan lives in this folder and opens with a `**Status:**` line; a plan list ## In progress -Nothing is in progress. +- **Staging that tracks main.** Audit of staging, the VPS sync timer, the seed, and what to delete. Plan: [2026-10-03 staging loop](2026-10-03-staging-loop.md). Previews come from #95 and the staging spec fix from #100. ## Backlog diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs new file mode 100644 index 000000000..a17ace8e1 --- /dev/null +++ b/scripts/staging-sync.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +// Brings the staging Supabase project up to the committed tree: applies +// pending migrations and deploys new or changed Edge Functions, then checks +// that nothing is left. Staging only: the project ref is fixed below and every +// Supabase CLI call passes it explicitly, so a linked checkout or a +// SUPABASE_PROJECT_ID pointing elsewhere cannot redirect it. +// +// The VPS timer in ops/staging-sync/ runs this on every new commit on main. +// See docs/staging.md. +// +// Usage: node scripts/staging-sync.mjs [--root ] [--dry-run] [--report] [--seed] +// --root checkout to read (default: the git top level of the cwd) +// --dry-run show what would change; change nothing +// --report set the `staging-sync` commit status on GitHub and open or close +// the drift issue. The staging browser suite waits for that status. +// --seed run scripts/staging/seed.mjs after a successful sync +// +// Exit codes: 0 in sync, 1 drift remains or a step failed, 2 refused or misused, +// 3 deferred because a staging browser job is running (try again later). + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; +import { join, relative } from "node:path"; +import { backendStatus } from "./backend-status.mjs"; + +export const STAGING_REF = "brifeyypjauyusmkptkx"; +export const STAGING_NAME = "debate-trainer-staging"; +export const PRODUCTION_REF = "uziwdtkzofptihahfooo"; +export const GITHUB_REPO = "Programcode/DebateLab"; +const STATUS_CONTEXT = "staging-sync"; +const DRIFT_ISSUE_TITLE = "Staging is out of sync with main"; + +/** Collaborative Workspace is hidden and production doesn't run it, so staging doesn't either. */ +export const SKIPPED_FUNCTIONS = new Set(["workspace-invites"]); + +class RefusedError extends Error {} +class DeferredError extends Error {} + +export function parseArgs(argv) { + const options = { root: null, dryRun: false, report: false, seed: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--report") options.report = true; + else if (arg === "--seed") options.seed = true; + else if (arg === "--root" && argv[index + 1]) options.root = argv[++index]; + else throw new RefusedError(`Unknown argument: ${arg}`); + } + return options; +} + +function run(command, args, { cwd, input } = {}) { + const result = spawnSync(command, args, { cwd, input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw new Error(`${command} ${args[0]} failed: ${result.error.message}`); + return result; +} + +function quiet(text) { + return (text ?? "") + .split("\n") + .filter((line) => !/new version of Supabase CLI|recommend updating regularly|^\s*$/i.test(line)) + .join("\n") + .replace(/(postgres(?:ql)?:\/\/)[^@\s]+@/gi, "$1***@") + .trim(); +} + +/** + * `--agent no` keeps the CLI's output the same under an agent (where it + * detects one and switches to JSON) and under the systemd timer (where it + * doesn't), so what an agent tests is what the timer runs. + */ +function supabase(root, args) { + const result = run("supabase", [...args, "--agent", "no", "--project-ref", STAGING_REF], { cwd: root }); + const output = quiet(`${result.stdout}\n${result.stderr}`); + if (result.status !== 0) throw new Error(`supabase ${args.slice(0, 2).join(" ")} failed:\n${output}`); + return output; +} + +/** Refuses to run unless the CLI sees the staging ref under the staging name. */ +export function assertStagingTarget(projects, env = process.env) { + const override = env.SUPABASE_PROJECT_ID?.trim(); + if (override && override !== STAGING_REF) { + throw new RefusedError(`SUPABASE_PROJECT_ID is ${override}; this script only targets staging (${STAGING_REF}).`); + } + if (STAGING_REF === PRODUCTION_REF) throw new RefusedError("Staging and production refs are the same."); + const staging = projects.find((project) => (project.ref ?? project.id) === STAGING_REF); + if (!staging) throw new RefusedError(`The Supabase account cannot see ${STAGING_REF}.`); + if (staging.name !== STAGING_NAME) { + throw new RefusedError(`${STAGING_REF} is named "${staging.name}", expected "${STAGING_NAME}".`); + } +} + +/** What the sync has to do, from a backend-status result. */ +export function planSync(result) { + const deploy = new Set([ + ...result.notDeployed, + ...result.stale.map((item) => item.name), + ...result.verifyJwt.map((item) => item.name), + ]); + for (const name of SKIPPED_FUNCTIONS) deploy.delete(name); + return { + divergent: result.unknownMigrations, + migrations: result.pendingMigrations, + functions: [...deploy].sort(), + undeclared: result.notInRepo.filter((name) => !SKIPPED_FUNCTIONS.has(name)), + }; +} + +/** Edge Functions in the checkout: folders under supabase/functions with an index.ts. */ +function repoFunctions(root) { + const base = join(root, "supabase/functions"); + return readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && existsSync(join(base, entry.name, "index.ts"))) + .map((entry) => entry.name) + .filter((name) => !SKIPPED_FUNCTIONS.has(name)); +} + +function filesUnder(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? filesUnder(join(dir, entry.name)) : [join(dir, entry.name)]); +} + +/** The source files staging runs for one function, keyed by path under supabase/functions. */ +function downloadDeployed(root, name) { + const dir = mkdtempSync(join(tmpdir(), "staging-fn-")); + try { + // The CLI writes supabase/.temp into its cwd, so download into a scratch dir. + supabase(dir, ["functions", "download", name, "--use-api"]); + const base = join(dir, "supabase/functions"); + return new Map(filesUnder(base).map((file) => [relative(base, file), readFileSync(file)])); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Functions whose deployed source differs from the commit. Deploy dates only + * say when something was deployed, not what, so an older checkout deployed + * after a merge would otherwise pass as current. + */ +export function contentDrift(root, names, download = (name) => downloadDeployed(root, name)) { + const base = join(root, "supabase/functions"); + return names.filter((name) => { + const deployed = download(name); + if (deployed.size === 0 || ![...deployed.keys()].includes(`${name}/index.ts`)) return true; + return [...deployed].some(([path, bytes]) => { + const local = join(base, path); + return !existsSync(local) || !readFileSync(local).equals(bytes); + }); + }); +} + +/** planSync plus a content check of every deployed function the date check passed. */ +function planWithContent(root, result, log) { + const plan = planSync(result); + const pending = new Set([...plan.functions, ...result.notDeployed]); + const drifted = contentDrift(root, repoFunctions(root).filter((name) => !pending.has(name))); + if (drifted.length > 0) log(`Deployed source differs from this commit: ${drifted.join(", ")}`); + return { ...plan, functions: [...new Set([...plan.functions, ...drifted])].sort() }; +} + +/** + * A function deployed on staging but gone from the commit stays callable. The + * sync never deletes functions, so it reports them as drift until someone + * removes them by hand (`supabase functions delete --project-ref ...`). + */ +export function assertNoUndeclared(plan) { + if (plan.undeclared.length > 0) { + throw new Error(`Deployed on staging but not in this commit: ${plan.undeclared.join(", ")}. Delete them by hand; see docs/staging.md.`); + } +} + +function lastJsonLine(text) { + for (const line of text.split("\n").reverse()) { + try { + return JSON.parse(line); + } catch { + // keep looking + } + } + return null; +} + +function sameList(a, b) { + return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index]); +} + +export function sync({ root, dryRun, log = console.log, beforeChange = () => {} }) { + const before = backendStatus({ root, projectRef: STAGING_REF }); + log(before.report, ""); + const plan = planWithContent(root, before.result, log); + + if (plan.divergent.length > 0) { + throw new Error(`Staging has migrations this commit lacks (${plan.divergent.join(", ")}). Not pushing over divergent history; fix it by hand.`); + } + if (plan.migrations.length === 0 && plan.functions.length === 0) { + assertNoUndeclared(plan); + log("Staging already matches this commit."); + return { plan, applied: [], deployed: [] }; + } + + if (!dryRun) beforeChange(); + const applied = []; + if (plan.migrations.length > 0) { + const dry = supabase(root, ["db", "push", "--dry-run", "--output-format", "json"]); + const wouldPush = (lastJsonLine(dry)?.migrations ?? []).map((file) => file.split("_")[0]); + if (!sameList(wouldPush, plan.migrations)) { + throw new Error(`db push would apply [${wouldPush.join(", ")}] but the pending list is [${plan.migrations.join(", ")}]. Stopping.`); + } + if (dryRun) log(`Would apply migrations: ${plan.migrations.join(", ")}`); + else { + log(`Applying migrations: ${plan.migrations.join(", ")}`); + supabase(root, ["db", "push", "--yes"]); + applied.push(...plan.migrations); + } + } + + const deployed = []; + for (const name of plan.functions) { + if (dryRun) { + log(`Would deploy ${name}`); + continue; + } + log(`Deploying ${name}`); + supabase(root, ["functions", "deploy", name, "--use-api"]); + deployed.push(name); + } + if (dryRun) return { plan, applied, deployed }; + + const after = backendStatus({ root, projectRef: STAGING_REF }); + const left = planWithContent(root, after.result, log); + if (left.migrations.length > 0 || left.functions.length > 0 || left.divergent.length > 0) { + log(after.report); + throw new Error(`Still pending after sync: ${[...left.migrations, ...left.functions].join(", ")}`); + } + assertNoUndeclared(left); + log(`Staging matches ${after.report.split("\n")[0].split(" ").pop()}.`); + return { plan, applied, deployed }; +} + +// --- GitHub reporting ------------------------------------------------------- + +function gh(args, input) { + const result = run("gh", args, { input }); + if (result.status !== 0) throw new Error(`gh ${args.slice(0, 2).join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout; +} + +function setStatus(sha, state, description) { + gh([ + "api", `repos/${GITHUB_REPO}/statuses/${sha}`, + "-f", `state=${state}`, + "-f", `context=${STATUS_CONTEXT}`, + "-f", `description=${description.slice(0, 139)}`, + ]); +} + +function currentStatus(sha) { + const statuses = JSON.parse(gh(["api", `repos/${GITHUB_REPO}/commits/${sha}/statuses`])); + return statuses.find((status) => status.context === STATUS_CONTEXT) ?? null; +} + +function browserJobRunning() { + const runs = JSON.parse(gh(["run", "list", "--repo", GITHUB_REPO, "--workflow", "staging-e2e.yml", "--status", "in_progress", "--limit", "100", "--json", "databaseId"])); + return runs.some(({ databaseId }) => JSON.parse(gh(["run", "view", String(databaseId), "--repo", GITHUB_REPO, "--json", "jobs"])) + .jobs.some((job) => job.name === "staging-e2e" && job.status === "in_progress")); +} + +/** + * Marks the commit pending before staging changes, so browser jobs that start + * from now on refuse to test (they need success), then checks once more that + * none started since run.sh looked. If one did, it changes nothing. + */ +export function claimStaging(sha) { + const previous = currentStatus(sha); + setStatus(sha, "pending", "Syncing staging; browser tests wait for this."); + if (browserJobRunning()) { + if (previous) setStatus(sha, previous.state, previous.description ?? ""); + throw new DeferredError("A staging browser job started; syncing on a later run."); + } +} + +function openDriftIssue() { + // Not --search: the search index lags, and a retry would open a duplicate. + const found = JSON.parse(gh([ + "issue", "list", "--repo", GITHUB_REPO, "--state", "open", "--limit", "200", "--json", "number,title", + ])); + return found.find((issue) => issue.title === DRIFT_ISSUE_TITLE)?.number ?? null; +} + +export function report({ sha, ok, summary, detail }) { + setStatus(sha, ok ? "success" : "failure", summary); + const issue = openDriftIssue(); + if (ok) { + if (issue) gh(["issue", "close", String(issue), "--repo", GITHUB_REPO, "--comment", `Staging synced at ${sha}. ${summary}`]); + return; + } + const body = `Staging sync failed at ${sha}.\n\n${summary}\n\n\`\`\`\n${detail.slice(-6000)}\n\`\`\`\n\nLogs: \`journalctl --user -u debatelab-staging-sync\` on the VPS. See docs/staging.md.`; + if (!issue) { + gh(["issue", "create", "--repo", GITHUB_REPO, "--title", DRIFT_ISSUE_TITLE, "--body-file", "-"], body); + return; + } + // One comment per failing commit, not one per retry. + const seen = gh(["issue", "view", String(issue), "--repo", GITHUB_REPO, "--json", "body,comments", "--jq", ".body, .comments[].body"]); + if (!seen.includes(sha)) gh(["issue", "comment", String(issue), "--repo", GITHUB_REPO, "--body-file", "-"], body); +} + +function main() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exit(2); + } + const root = options.root + ?? run("git", ["rev-parse", "--show-toplevel"]).stdout.trim(); + const sha = run("git", ["rev-parse", "HEAD"], { cwd: root }).stdout.trim(); + + try { + const listed = run("supabase", ["projects", "list", "--output-format", "json"], { cwd: root }); + if (listed.status !== 0) throw new RefusedError(`supabase projects list failed: ${quiet(listed.stderr)}`); + const parsed = JSON.parse(listed.stdout); + assertStagingTarget(Array.isArray(parsed) ? parsed : parsed.projects ?? []); + const dirty = run("git", ["status", "--porcelain", "--", "supabase"], { cwd: root }).stdout.trim(); + if (dirty) throw new RefusedError("Uncommitted changes under supabase/. Sync only from a clean commit."); + } catch (error) { + console.error(`Refused: ${error.message}`); + if (options.report && !options.dryRun) { + try { + report({ sha, ok: false, summary: `Refused: ${error.message.split("\n")[0]}`, detail: error.message }); + } catch (reportError) { + console.error(`Reporting failed too: ${reportError.message}`); + } + } + process.exit(2); + } + + const lines = []; + const log = (...parts) => { + const text = parts.join("\n"); + lines.push(text); + console.log(text); + }; + try { + const beforeChange = options.report ? () => claimStaging(sha) : undefined; + const { applied, deployed } = sync({ root, dryRun: options.dryRun, log, beforeChange }); + if (options.seed && !options.dryRun) { + const seeded = run("node", [join(root, "scripts/staging/seed.mjs")], { cwd: root }); + log(quiet(seeded.stdout), quiet(seeded.stderr)); + if (seeded.status !== 0) throw new Error("Seeding staging failed."); + } + const summary = applied.length + deployed.length === 0 + ? `In sync with ${sha.slice(0, 7)}.` + : `Applied ${applied.length} migration(s), deployed ${deployed.length} function(s) at ${sha.slice(0, 7)}.`; + if (options.report && !options.dryRun) report({ sha, ok: true, summary, detail: lines.join("\n") }); + } catch (error) { + console.error(error.message); + if (error instanceof DeferredError) process.exit(3); + if (options.report && !options.dryRun) { + try { + report({ sha, ok: false, summary: error.message.split("\n")[0], detail: `${lines.join("\n")}\n${error.message}` }); + } catch (reportError) { + console.error(`Reporting failed too: ${reportError.message}`); + } + } + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/staging/fixtures.mjs b/scripts/staging/fixtures.mjs new file mode 100644 index 000000000..af4aaa491 --- /dev/null +++ b/scripts/staging/fixtures.mjs @@ -0,0 +1,271 @@ +// Invented content for the staging seed (scripts/staging/seed.mjs). Every +// person, publication, figure and URL here is made up. Row ids are fixed so a +// rerun updates rows instead of adding copies. Output shapes follow what the +// client saves and renders: GenerateOutput, AttackOutput, BrainstormOutput +// (src/lib/ai/schemas/) and FactoidsOutput (src/lib/ai/schemas/factoids.ts). + +const id = (n) => `5eed0000-0000-4000-8000-${String(n).padStart(12, "0")}`; + +const contention = (n, claim, reasoning, evidence, impact) => ({ + id: String(n), claim, reasoning, evidence, needsSource: false, impact, +}); + +const idea = (n, side, title, description, strength = "strong") => ({ + id: `i${n}`, title, description, side, strength, tradeoffs: [], relatedTo: [], +}); + +const factoid = (n, shortClaim, keyFigure, context, publisher, url, relevanceScore) => ({ + id: `f${n}`, + shortClaim, + keyFigure, + context, + limitations: "Single fictional study; treat as illustrative.", + citation: { title: shortClaim, publisher, date: "2026-03-14", url, verified: "verified" }, + howToUse: "Use it as the impact link in your second contention.", + relevanceScore, +}); + +const source = (n, title, publication, url, paragraphs) => ({ + id: id(n), + title, + type: "url", + origin: url, + folder: "General", + is_active: true, + metadata: { url, sourceType: "news", qualityLabel: "moderate", capturedVia: "staging-seed" }, + content: [`${title}\n${publication}`, ...paragraphs].join("\n\n"), +}); + +export const DEBATES = [ + { + id: id(1), + motion: "This House would ban private cars from city centres", + settings: { format: "bp", role: "r1" }, + instructions: "Opening Government. Keep the case on public health and street space; avoid the climate framing, the judges have heard it all season.", + sources: [ + source(101, "Car-free cores: what three mid-sized cities learned", "The Northfield Ledger (fictional)", "https://example.com/northfield/car-free-cores", [ + "When the fictional city of Ardmoor closed its historic core to private cars in 2021, shop owners predicted ruin. Three years on, the city's own footfall counters show weekday visits up 18 percent, with the strongest growth in cafes and small grocers.", + "The picture is not uniformly positive. Deliveries now cluster in a two-hour morning window, and disability advocates say the shuttle service that replaced door-to-door access runs too rarely after 7 pm.", + "Planners in Ardmoor and its neighbours agree on one lesson: the ban worked where frequent buses and protected cycle lanes arrived before the closure, not after it.", + ]), + source(102, "Air quality after the Ardmoor closure", "Institute for Urban Health Studies (fictional)", "https://example.org/iuhs/ardmoor-air", [ + "Nitrogen dioxide readings at four central monitoring stations fell by an average of 31 percent in the first year after private cars were excluded, against 6 percent at comparable stations outside the zone.", + "Hospital admissions for childhood asthma in the central postcode area fell from 112 to 87 over the same period. The authors caution that the sample is small and that admissions were already trending down.", + ]), + source(103, "The hidden cost of car-free zones", "Metro Business Review (fictional)", "https://example.com/mbr/hidden-cost", [ + "Retail landlords on the edge of car-free districts report vacancy rates two to three points higher than before the policy, as larger stores relocate to out-of-town parks with free parking.", + "Tradespeople are the loudest critics. A plumber carrying 40 kilograms of tools cannot take the tram, and permit schemes for commercial vans have become a paperwork burden.", + ]), + ], + results: { + cases: [{ + id: id(201), + title: "Car-free centres: OG case", + side: "pro", + output: { + caseStructure: { + thesis: "City centres belong to the people who use them on foot, and private cars are the single largest obstacle to that.", + framework: "Weigh public health and equal access to shared space against the convenience of a minority of drivers.", + keyDefinitions: ["City centre: the historic commercial core, roughly the area inside the inner ring road.", "Private car: privately owned passenger vehicles; buses, taxis, emergency and delivery vehicles are exempt."], + burdenOfProof: "Show that the health and access gains outweigh the cost to drivers and businesses, with a workable transition.", + }, + contentions: [ + contention(1, "Removing cars cuts air pollution where the most people breathe it.", "Central streets concentrate both traffic and pedestrians, so exposure falls fastest there.", "Ardmoor's central stations recorded a 31 percent drop in nitrogen dioxide in year one (Institute for Urban Health Studies, fictional).", "Fewer childhood asthma admissions and better long-term lung health."), + contention(2, "Street space is a public good that cars monopolise.", "A parked car occupies roughly 12 square metres for most of the day while moving almost nobody.", "Ardmoor turned 40 percent of its former parking into seating, cycle lanes and market space.", "Space is redistributed to the majority who arrive on foot, by bike or by bus."), + contention(3, "Car-free cores revive local commerce.", "Pedestrians linger and make more frequent small purchases than drivers.", "Weekday footfall in Ardmoor rose 18 percent after the closure.", "Small businesses gain at the expense of out-of-town retail parks."), + ], + predictedOpponentCase: { + likelyArguments: ["Disabled and elderly residents lose door-to-door access.", "Tradespeople and businesses face higher costs.", "Displacement just moves congestion to the ring road."], + strategicWeaknesses: ["Opposition must defend the status quo of pollution in the densest areas.", "Access concerns are answerable with exemptions and shuttles."], + keyClashes: ["Who bears the cost of the transition.", "Whether exemptions make the ban unworkable."], + }, + counters: [{ + targetArgument: "Accessibility", + counterpoint: "Blue-badge holders, taxis and on-demand shuttles are exempt in our model.", + rebuttal: "Today's car-dominated streets are already hostile to wheelchair users; wider pavements help them most.", + }], + metadata: { side: "pro", format: "bp", generatedAt: "2026-09-02T09:14:00.000Z" }, + }, + }], + attacks: [{ + id: id(211), + title: "Stress test: the commerce contention", + side: "pro", + output: { + overallAssessment: { + score: 64, + summary: "The health line is solid, but the commerce contention leans on one city and invites an easy counterexample.", + strengthAreas: ["Clear framework", "Concrete pollution evidence"], + }, + weaknesses: [{ + id: "w1", + severity: "major", + type: "evidential", + description: "Footfall is not the same as spending.", + whyWeak: "Opposition can grant more visitors and still argue that revenue moved to retail parks.", + howOpponentAttacks: "Cite the vacancy rates on the zone's edge from Metro Business Review.", + improvedVersion: "Pair footfall with turnover data for independent shops, and concede that large-format retail moves out.", + }, { + id: "w2", + severity: "minor", + type: "logical", + description: "The space argument assumes freed space is used well.", + whyWeak: "Empty plazas are a common failure mode.", + howOpponentAttacks: "Ask what happens to the space in winter.", + improvedVersion: "Commit to a mechanism: market licences and seating funded from the old parking revenue.", + }], + fallaciesDetected: [{ name: "Hasty generalisation", location: "Contention 3", explanation: "One fictional city's footfall is extended to every city centre." }], + crossExQuestions: [{ question: "Where does the plumber park?", purpose: "Expose the commercial access gap.", expectedTrap: "A promise of permits without a cap on the paperwork." }], + weighingAnalysis: { + currentWeighing: "Health is asserted to outweigh business cost without comparing magnitudes.", + missingComparisons: ["Hospital costs avoided versus revenue lost."], + improvementSuggestions: ["Weigh on reversibility: lost health is permanent, lost trade can return."], + }, + }, + }], + factoids: [{ + id: id(221), + title: "Evidence: pollution and footfall", + side: "pro", + output: { + factoids: [ + factoid(1, "Central NO2 fell sharply after the Ardmoor closure", "31% drop in year one", "Average across four central monitoring stations, against 6% outside the zone.", "Institute for Urban Health Studies (fictional)", "https://example.org/iuhs/ardmoor-air", 92), + factoid(2, "Weekday footfall rose in the car-free core", "+18% weekday visits", "City footfall counters, three years after the closure.", "The Northfield Ledger (fictional)", "https://example.com/northfield/car-free-cores", 81), + ], + searchSuggestions: ["turnover of independent shops in car-free zones"], + warnings: [], + verificationStatus: "complete", + }, + }], + brainstorms: [], + }, + chats: [], + }, + { + id: id(2), + motion: "This House believes that social media companies should be legally liable for misinformation on their platforms", + settings: { format: "policy", role: "general" }, + instructions: "We are Opposition. The judge is a law student, so expect questions on mechanism and free expression.", + sources: [ + source(111, "Liability regimes compared", "Comparative Media Law Quarterly (fictional)", "https://example.org/cmlq/liability-regimes", [ + "Of the twelve fictional jurisdictions surveyed, the four that introduced platform liability for false content saw takedown volumes rise fivefold within a year. Independent audits found that roughly a third of removed posts were lawful opinion or satire.", + "Smaller platforms responded differently from large ones: two exited the regulated markets entirely, citing compliance costs equal to a quarter of their revenue.", + ]), + source(112, "Who decides what is false?", "The Civic Review (fictional)", "https://example.com/civic-review/who-decides", [ + "Fact-checking bodies disagree with each other more often than the public assumes. In a fictional panel study, three established fact-checkers reached the same verdict on only 61 percent of contested health claims.", + "When liability attaches to the platform, the cheapest compliance strategy is to remove anything contested, the author argues.", + ]), + ], + results: { + cases: [{ + id: id(231), + title: "Opposition case: liability chills speech", + side: "con", + output: { + caseStructure: { + thesis: "Making platforms liable for misinformation hands private companies a legal incentive to silence anything contested.", + framework: "Protect the conditions for open public debate; weigh harms by who decides what counts as false.", + keyDefinitions: ["Legally liable: exposed to civil damages or regulatory fines for user-posted false content."], + burdenOfProof: "Show that liability causes more harm to legitimate speech than it prevents in misinformation.", + }, + contentions: [ + contention(1, "Liability drives over-removal.", "Platforms face fines for leaving content up but nothing for taking it down.", "Takedowns rose fivefold under liability regimes, with a third of removals lawful (Comparative Media Law Quarterly, fictional).", "Lawful dissent, satire and minority views disappear first."), + contention(2, "Truth is contested, and liability freezes one answer.", "Today's misinformation is sometimes tomorrow's consensus.", "Fact-checkers agreed on only 61 percent of contested health claims (The Civic Review, fictional).", "Science and politics lose the ability to correct themselves in public."), + ], + predictedOpponentCase: { + likelyArguments: ["Misinformation causes real deaths, for example vaccine hesitancy.", "Platforms profit from engagement with false content."], + strategicWeaknesses: ["Government must define misinformation precisely enough to litigate."], + keyClashes: ["Over-removal versus under-removal.", "Who holds the definitional power."], + }, + counters: [{ + targetArgument: "Platforms profit from falsehood", + counterpoint: "Fix the incentive directly with transparency and algorithmic audits.", + rebuttal: "Liability punishes the speech, not the amplification model.", + }], + metadata: { side: "con", format: "policy", generatedAt: "2026-09-08T16:40:00.000Z" }, + }, + }], + brainstorms: [{ + id: id(241), + title: "Brainstorm: platform liability", + side: "both", + output: { + needsMoreInfo: false, + topFive: [ + { rank: 1, idea: idea(1, "con", "Collateral censorship", "Intermediaries remove more than the law requires because the costs of error are one-sided."), whyTop: "It is the core mechanism and hard for Government to deny." }, + { rank: 2, idea: idea(2, "pro", "Engagement profits from falsehood", "Ranking systems reward outrage, and outrage correlates with falsehood."), whyTop: "Government's strongest moral claim; prep a response." }, + { rank: 3, idea: idea(3, "con", "Market exit of small platforms", "Compliance costs entrench the largest incumbents."), whyTop: "Turns Government's anti-big-tech instinct against them." }, + { rank: 4, idea: idea(4, "pro", "Notice-and-action is already normal", "Copyright law already makes platforms act on notice.", "moderate"), whyTop: "Government's precedent argument." }, + { rank: 5, idea: idea(5, "con", "Definitional capture", "Whoever writes the definition of misinformation gains a censorship tool.", "moderate"), whyTop: "Strong in front of a law-trained judge." }, + ], + nextSteps: ["Find a comparative study on over-removal.", "Prepare an answer to the copyright precedent."], + warningsAndTradeoffs: ["Don't defend misinformation itself; attack the mechanism."], + }, + }], + attacks: [], + factoids: [], + }, + chats: [{ + id: id(251), + title: "How to answer the vaccine example", + side: "con", + messages: [ + { id: id(252), role: "user", content: "Government will open with vaccine misinformation causing deaths. How do we answer without sounding callous?" }, + { id: id(253), role: "assistant", content: "Grant the harm early and move the clash to mechanism. Say: 'We agree vaccine falsehoods kill. The question is whether fining platforms reduces them or just removes everything near the topic, including doctors raising legitimate side-effect questions.' Then use the over-removal figure from your first contention." }, + { id: id(254), role: "user", content: "And if they say transparency rules are too weak?" }, + { id: id(255), role: "assistant", content: "Ask what evidence shows liability outperforms transparency. Their burden is comparative: they need liability to beat the best alternative, not the status quo." }, + ], + }], + }, + { + id: id(3), + motion: "This House believes that standardised testing does more harm than good", + settings: { format: "classroom", role: "general" }, + instructions: "Practice debate for the Year 10 club. Keep language accessible.", + sources: [ + source(121, "Teaching to the test: a five-year classroom study", "Journal of School Practice (fictional)", "https://example.org/jsp/teaching-to-the-test", [ + "Across 48 fictional secondary schools, teachers in high-stakes testing years spent an average of 11 weeks per year on direct exam preparation, compared with 4 weeks in years without national tests.", + "Pupils' scores on the national test rose, but their performance on an unseen problem-solving task did not change.", + ]), + source(122, "Tests as a safeguard for disadvantaged pupils", "Education Equity Forum (fictional)", "https://example.com/eef/tests-as-safeguard", [ + "Where schools dropped standardised tests in favour of teacher assessment, the attainment gap between pupils on free school meals and their peers appeared to narrow, but blind re-marking showed teacher grades favoured pupils whose behaviour was rated as good.", + "Without a common yardstick, the author argues, under-performing schools serving poorer areas are harder to identify and support.", + ]), + ], + results: { + brainstorms: [{ + id: id(261), + title: "Brainstorm: standardised testing", + side: "pro", + output: { + needsMoreInfo: false, + topFive: [ + { rank: 1, idea: idea(11, "pro", "Teaching to the test narrows learning", "Weeks of exam drill crowd out curiosity and problem solving."), whyTop: "Concrete, relatable to the Year 10 audience." }, + { rank: 2, idea: idea(12, "pro", "Test anxiety harms wellbeing", "High stakes create stress that falls hardest on younger pupils.", "moderate"), whyTop: "Emotional weight for a classroom judge." }, + { rank: 3, idea: idea(13, "con", "Tests protect disadvantaged pupils", "A common yardstick exposes schools that fail poorer pupils."), whyTop: "The strongest opposition line; prepare for it." }, + { rank: 4, idea: idea(14, "pro", "Scores measure the school's catchment, not its teaching", "Results track family income more than teaching quality.", "moderate"), whyTop: "Undermines the accountability argument." }, + { rank: 5, idea: idea(15, "con", "Teacher assessment is biased", "Unblinded grades reflect behaviour and background.", "moderate"), whyTop: "Opposition's answer to idea 4." }, + ], + nextSteps: ["Find wellbeing data for exam years."], + warningsAndTradeoffs: ["Don't claim tests have no value; argue the harms outweigh it."], + }, + }], + factoids: [{ + id: id(271), + title: "Evidence: exam preparation time", + side: "pro", + output: { + factoids: [ + factoid(3, "Exam years consume far more prep time", "11 weeks vs 4 weeks", "Average direct exam preparation per year across 48 schools.", "Journal of School Practice (fictional)", "https://example.org/jsp/teaching-to-the-test", 88), + ], + searchSuggestions: ["pupil wellbeing surveys in national test years"], + warnings: ["Opposition will cite the teacher-assessment bias finding."], + verificationStatus: "complete", + }, + }], + cases: [], + attacks: [], + }, + chats: [], + }, +]; diff --git a/scripts/staging/seed.mjs b/scripts/staging/seed.mjs new file mode 100644 index 000000000..d9cf52340 --- /dev/null +++ b/scripts/staging/seed.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +// Seeds the staging Supabase project with invented but realistic data: a few +// accounts on different plans, debates with sources and saved results, and a +// demo code for the Pro account. Safe to rerun: rows have fixed ids per owner +// and are upserted, so a rerun restores the seed without duplicating it. +// +// Staging only. The project URL is fixed, and the service-role key is fetched +// with the Supabase CLI and checked to belong to staging before any write. +// +// Reads and writes ~/.config/debatelab/staging-access.env (mode 600): +// STAGING_OWNER_EMAIL Adam's Google account; gets admin, Pro and the debates +// STAGING_SEED_PRO_CODE demo code for the seeded Pro account (written here) +// +// Usage: +// node scripts/staging/seed.mjs seed or restore everything +// node scripts/staging/seed.mjs --session print a session for a seeded +// account (admin, pro, trial, expired, owner) as JSON for the browser's +// `debate-auth-staging` localStorage key. Demo codes turn an account into +// Pro, so this is how agents sign in as trial or expired. +// node scripts/staging/seed.mjs --owner-tier +// put Adam's account on another plan until the next seed run +// See docs/staging.md. + +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DEBATES } from "./fixtures.mjs"; + +export const STAGING_REF = "brifeyypjauyusmkptkx"; +const URL_BASE = `https://${STAGING_REF}.supabase.co`; +const ACCESS_FILE = process.env.STAGING_ACCESS_FILE ?? join(homedir(), ".config/debatelab/staging-access.env"); +const DOMAIN = "staging.debatelab.test"; +const PRO_CODE_LABEL = "Staging seed: Pro debater"; + +const DAY = 24 * 60 * 60 * 1000; + +/** Seeded accounts. `prefix` keeps each owner's copy of the fixtures apart. */ +export function seedAccounts(ownerEmail, now = Date.now()) { + const accounts = [ + { key: "admin", email: `seed-admin@${DOMAIN}`, role: "admin", tier: "pro", prefix: null }, + { key: "pro", email: `seed-pro@${DOMAIN}`, role: "user", tier: "pro", prefix: "5eed0001", debates: "all" }, + { key: "trial", email: `seed-trial@${DOMAIN}`, role: "user", tier: "trial", trialEndsAt: new Date(now + 5 * DAY).toISOString(), prefix: "5eed0002", debates: [2] }, + { key: "expired", email: `seed-expired@${DOMAIN}`, role: "user", tier: "expired", trialEndsAt: new Date(now - 10 * DAY).toISOString(), prefix: "5eed0003", debates: [0] }, + ]; + if (ownerEmail) accounts.push({ key: "owner", email: ownerEmail.toLowerCase(), role: "admin", tier: "pro", prefix: "5eed0004", debates: "all" }); + return accounts; +} + +/** Gives one owner their own copy of the fixture ids. */ +export function ownedCopy(value, prefix) { + return JSON.parse(JSON.stringify(value).replaceAll("5eed0000-", `${prefix}-`)); +} + +function readAccessFile() { + if (!existsSync(ACCESS_FILE)) return {}; + return Object.fromEntries(readFileSync(ACCESS_FILE, "utf8").split("\n") + .map((line) => /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())) + .filter(Boolean) + .map(([, key, value]) => [key, value])); +} + +function writeAccessFile(values) { + mkdirSync(dirname(ACCESS_FILE), { recursive: true, mode: 0o700 }); + const body = [ + "# Staging access for DebateLab. Staging only; never commit or paste these. See docs/staging.md.", + ...Object.entries(values).map(([key, value]) => `${key}=${value}`), + "", + ].join("\n"); + writeFileSync(ACCESS_FILE, body, { mode: 0o600 }); + chmodSync(ACCESS_FILE, 0o600); +} + +function jwtClaims(token) { + try { + return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); + } catch { + return {}; + } +} + +/** Fetches staging's anon and service-role keys and proves they belong to staging. */ +function stagingKeys() { + const result = spawnSync("supabase", ["projects", "api-keys", "--project-ref", STAGING_REF, "--output-format", "json"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`supabase projects api-keys failed: ${result.stderr.split("\n").find((line) => line.trim()) ?? ""}`); + const parsed = JSON.parse(result.stdout); + const keys = Array.isArray(parsed) ? parsed : parsed.keys ?? []; + const find = (name) => keys.find((key) => key.name === name)?.api_key; + const anon = find("anon"); + const service = find("service_role"); + for (const [token, role] of [[anon, "anon"], [service, "service_role"]]) { + const claims = jwtClaims(token ?? ""); + if (claims.ref !== STAGING_REF || claims.role !== role) throw new Error(`Refusing: the ${role} key is not staging's.`); + } + return { anon, service }; +} + +function client({ anon, service }) { + async function request(path, { method = "GET", body, token = service, prefer, apikey = service } = {}) { + const response = await fetch(`${URL_BASE}${path}`, { + method, + headers: { + apikey, + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(prefer ? { Prefer: prefer } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`${method} ${path.split("?")[0]} answered ${response.status}: ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; + } + const upsert = (table, rows, onConflict = "id") => rows.length === 0 ? null : request( + `/rest/v1/${table}?on_conflict=${onConflict}`, + { method: "POST", body: rows, prefer: "resolution=merge-duplicates,return=minimal" }, + ); + return { request, upsert, anon }; +} + +async function listUsers(api) { + const users = []; + for (let page = 1; ; page += 1) { + const { users: batch } = await api.request(`/auth/v1/admin/users?page=${page}&per_page=200`); + users.push(...batch); + if (batch.length < 200) return users; + } +} + +async function ensureUser(api, existing, email, appMetadata) { + const found = existing.find((user) => user.email?.toLowerCase() === email); + if (found) { + if (appMetadata && Object.entries(appMetadata).some(([key, value]) => found.app_metadata?.[key] !== value)) { + return api.request(`/auth/v1/admin/users/${found.id}`, { + method: "PUT", + body: { app_metadata: { ...found.app_metadata, ...appMetadata } }, + }); + } + return found; + } + return api.request("/auth/v1/admin/users", { + method: "POST", + body: { email, email_confirm: true, app_metadata: appMetadata ?? {}, user_metadata: { seeded: true } }, + }); +} + +/** A session for a seeded account, minted with the service role without sending mail. */ +async function sessionFor(api, email) { + const link = await api.request("/auth/v1/admin/generate_link", { method: "POST", body: { type: "magiclink", email } }); + const tokenHash = link.hashed_token ?? link.properties?.hashed_token; + const session = await api.request("/auth/v1/verify", { + method: "POST", + body: { type: "magiclink", token_hash: tokenHash }, + token: api.anon, + apikey: api.anon, + }); + return session; +} + +/** Makes sure the seeded Pro account has a demo code we know. */ +async function ensureProCode(api, access, adminEmail, proEmail) { + const rows = await api.request(`/rest/v1/demo_access_codes?select=id,disabled_at&email=eq.${encodeURIComponent(proEmail)}`); + const row = rows[0]; + if (row && !row.disabled_at && access.STAGING_SEED_PRO_CODE) return row.id; + // The raw code is shown only once. If we lost it, replace the row. + if (row) await api.request(`/rest/v1/demo_access_codes?id=eq.${row.id}`, { method: "DELETE" }); + const { access_token: token } = await sessionFor(api, adminEmail); + const created = await api.request("/functions/v1/admin?action=create-demo-code", { + method: "POST", + token, + apikey: api.anon, + body: { label: PRO_CODE_LABEL, email: proEmail }, + }); + access.STAGING_SEED_PRO_CODE = created.code; + writeAccessFile(access); + return created.demoCode.id; +} + +async function seedContent(api, userId, prefix, debateIndexes) { + const debates = (debateIndexes === "all" ? DEBATES : debateIndexes.map((index) => DEBATES[index])) + .map((debate) => ownedCopy(debate, prefix)); + const owned = (row) => ({ ...row, user_id: userId }); + await api.upsert("debates", debates.map(({ id, motion, settings, instructions }) => owned({ id, motion, settings, instructions }))); + for (const debate of debates) { + const inDebate = (row) => owned({ ...row, debate_id: debate.id }); + await api.upsert("sources", debate.sources.map(inDebate)); + for (const table of ["cases", "attacks", "brainstorms", "factoids"]) { + await api.upsert(table, debate.results[table].map(({ id, title, side, output }) => inDebate({ id, title, side, input: {}, output }))); + } + await api.upsert("chats", debate.chats.map(({ id, title, side }) => inDebate({ id, title, side }))); + // Chats load ordered by created_at alone, so give each message its own time. + const base = Date.parse("2026-09-28T18:00:00Z"); + await api.upsert("messages", debate.chats.flatMap((chat) => chat.messages.map((message, index) => ({ + ...message, + chat_id: chat.id, + output: null, + created_at: new Date(base + index * 60_000).toISOString(), + })))); + } + if (debates[0]) await api.upsert("current_debates", [{ user_id: userId, debate_id: debates[0].id }], "user_id"); + return debates.length; +} + +export async function seed({ log = console.log } = {}) { + const access = readAccessFile(); + const api = client(stagingKeys()); + const accounts = seedAccounts(access.STAGING_OWNER_EMAIL); + const byKey = Object.fromEntries(accounts.map((account) => [account.key, account])); + let users = await listUsers(api); + + const admin = await ensureUser(api, users, byKey.admin.email); + await api.upsert("user_roles", [{ user_id: admin.id, role: "admin" }], "user_id"); + const codeId = await ensureProCode(api, access, byKey.admin.email, byKey.pro.email); + users = await listUsers(api); + + for (const account of accounts) { + const metadata = account.key === "pro" ? { demo_access_code_id: codeId } : undefined; + const user = await ensureUser(api, users, account.email, metadata); + await api.upsert("user_roles", [{ user_id: user.id, role: account.role }], "user_id"); + await api.upsert("user_settings", [{ user_id: user.id, ...tierSettings(account) }], "user_id"); + const count = account.prefix ? await seedContent(api, user.id, account.prefix, account.debates) : 0; + log(`${account.key}: ${account.tier}${account.role === "admin" ? ", admin" : ""}, ${count} debate(s)`); + } + if (!access.STAGING_OWNER_EMAIL) log(`No STAGING_OWNER_EMAIL in ${ACCESS_FILE}; skipped the owner account.`); + log(`Seeded staging. Access details: ${ACCESS_FILE}`); +} + +/** Plan fields for a seeded account, as the seed writes them. */ +function tierSettings(account) { + return { + subscription_tier: account.tier, + trial_ends_at: account.trialEndsAt ?? null, + daily_limit_override: account.tier === "pro" ? 200 : null, + grace_period_ends_at: account.tier === "pro" ? "9999-12-31T23:59:59.999Z" : null, + }; +} + +async function printSession(key) { + const account = seedAccounts(readAccessFile().STAGING_OWNER_EMAIL).find((candidate) => candidate.key === key); + if (!account) throw new Error(`Unknown account "${key}". Use admin, pro, trial, expired or owner.`); + const api = client(stagingKeys()); + if (!(await listUsers(api)).some((user) => user.email?.toLowerCase() === account.email)) { + throw new Error(`${account.email} is not on staging yet. Run the seed first.`); + } + process.stdout.write(`${JSON.stringify(await sessionFor(api, account.email))}\n`); +} + +async function setOwnerTier(tier) { + const access = readAccessFile(); + const owner = seedAccounts(access.STAGING_OWNER_EMAIL).find((account) => account.key === "owner"); + if (!owner) throw new Error(`No STAGING_OWNER_EMAIL in ${ACCESS_FILE}.`); + const template = seedAccounts(null).find((account) => account.tier === tier); + if (!template) throw new Error(`Unknown plan "${tier}". Use pro, trial or expired.`); + const api = client(stagingKeys()); + const user = (await listUsers(api)).find((candidate) => candidate.email?.toLowerCase() === owner.email); + if (!user) throw new Error("The owner account is not on staging yet. Sign in with Google once, or run the seed."); + await api.upsert("user_settings", [{ user_id: user.id, ...tierSettings(template) }], "user_id"); + console.log(`Owner account is on ${tier} until the next seed run.`); +} + +function main(argv) { + if (argv[0] === "--session" && argv[1]) return printSession(argv[1]); + if (argv[0] === "--owner-tier" && argv[1]) return setOwnerTier(argv[1]); + if (argv.length > 0) throw new Error(`Unknown arguments: ${argv.join(" ")}`); + return seed(); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + Promise.resolve().then(() => main(process.argv.slice(2))).catch((error) => { + console.error(`scripts/staging/seed.mjs failed: ${error.message}`); + process.exit(1); + }); +} diff --git a/src/test/scripts/staging-sync.test.ts b/src/test/scripts/staging-sync.test.ts new file mode 100644 index 000000000..42ca4bcb5 --- /dev/null +++ b/src/test/scripts/staging-sync.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +// @ts-expect-error -- plain ESM script without type declarations +import { assertNoUndeclared, assertStagingTarget, contentDrift, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; + +const staging = { ref: STAGING_REF, name: "debate-trainer-staging" }; +const production = { ref: PRODUCTION_REF, name: "debate-trainer" }; + +describe("staging sync target guard", () => { + it("accepts the staging project under its own name", () => { + expect(() => assertStagingTarget([production, staging], {})).not.toThrow(); + }); + + it("refuses when SUPABASE_PROJECT_ID points anywhere else", () => { + expect(() => assertStagingTarget([production, staging], { SUPABASE_PROJECT_ID: PRODUCTION_REF })) + .toThrow(/only targets staging/); + }); + + it("refuses when the staging ref is missing or renamed", () => { + expect(() => assertStagingTarget([production], {})).toThrow(/cannot see/); + expect(() => assertStagingTarget([{ ref: STAGING_REF, name: "debate-trainer" }], {})).toThrow(/expected "debate-trainer-staging"/); + }); +}); + +describe("staging sync plan", () => { + it("deploys missing, stale and wrong-JWT functions once each, and skips the hidden workspace function", () => { + const plan = planSync({ + unknownMigrations: [], + pendingMigrations: ["20261002000000"], + notDeployed: ["ai", "workspace-invites"], + stale: [{ name: "ai" }, { name: "share" }], + verifyJwt: [{ name: "account" }], + notInRepo: ["workspace-invites", "old-fn"], + }); + expect(plan).toEqual({ + divergent: [], + migrations: ["20261002000000"], + functions: ["account", "ai", "share"], + undeclared: ["old-fn"], + }); + // A function removed from main but still deployed fails the sync instead of passing quietly. + expect(() => assertNoUndeclared(plan)).toThrow(/old-fn/); + }); +}); + +describe("staging sync content check", () => { + const root = mkdtempSync(join(tmpdir(), "staging-sync-test-")); + for (const [path, text] of [["ai/index.ts", "ai v2"], ["_shared/cors.ts", "cors v2"], ["share/index.ts", "share"]]) { + mkdirSync(join(root, "supabase/functions", path, ".."), { recursive: true }); + writeFileSync(join(root, "supabase/functions", path), text); + } + const deployed: Record> = { + ai: { "ai/index.ts": "ai v2", "_shared/cors.ts": "cors v1" }, // older shared code deployed later + share: { "share/index.ts": "share" }, + gone: { "gone/index.ts": "x", "gone/extra.ts": "y" }, + empty: {}, + }; + const download = (name: string) => new Map(Object.entries(deployed[name]).map(([path, text]) => [path, Buffer.from(text)])); + + it("flags a function whose deployed source differs, even in a shared file", () => { + expect(contentDrift(root, ["ai", "share"], download)).toEqual(["ai"]); + }); + + it("flags files the commit lacks and downloads with no entry point", () => { + expect(contentDrift(root, ["gone", "empty"], download)).toEqual(["gone", "empty"]); + }); +});