diff --git a/.github/workflows/staging-cron.yml b/.github/workflows/staging-cron.yml new file mode 100644 index 00000000..d882f903 --- /dev/null +++ b/.github/workflows/staging-cron.yml @@ -0,0 +1,115 @@ +name: Staging cron (beta.sona.fast) + +# Scheduled cron for the STAGING site only. Cloudflare Pages can't run native +# cron, so GitHub Actions is the scheduler: it POSTs the app's cron endpoints +# with a Bearer CRON_SECRET. This mirrors the three fork cron workflows +# (sticker-resync.yml, artist-sync.yml, cleanup-orphans.yml) but targets the +# fixed staging base URL and is gated to the canonical repo only. +# +# The three schedules keep each fork workflow's cadence. A single workflow_dispatch +# runs all three on demand from the Actions tab. +# +# Required: repo secret CRON_SECRET matching the sona-staging Pages secret of the +# same name. Unset = each job warns and skips (optional opt-in, same as forks). + +on: + schedule: + - cron: '0 6 * * *' # sticker re-sync — daily 06:00 UTC + - cron: '30 6 * * *' # artist registry sync — daily 06:30 UTC + - cron: '0 7 * * 1' # storage orphan cleanup — weekly, Mondays 07:00 UTC + workflow_dispatch: {} + +# The staging base URL is fixed (not a repo var like the forks) because this +# workflow only ever runs against beta.sona.fast. +env: + STAGING_URL: https://beta.sona.fast + +jobs: + sticker-resync: + runs-on: ubuntu-latest + # Canonical repo only; on schedule, only the 06:00 cron. workflow_dispatch + # runs every job. + if: >- + github.repository == 'sona-fast/sona' && + (github.event_name == 'workflow_dispatch' || github.event.schedule == '0 6 * * *') + concurrency: + group: staging-sticker-resync + cancel-in-progress: false + steps: + - name: Trigger re-sync endpoint + env: + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$CRON_SECRET" ]; then + echo "::warning::CRON_SECRET not set — skipping staging sticker re-sync (set the repo secret to opt in)"; exit 0 + fi + code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $CRON_SECRET" \ + "${STAGING_URL%/}/api/cron/resync-telegram") + echo "HTTP $code"; cat response.json || true; echo + if [ "$code" != "200" ]; then + echo "::error::Re-sync endpoint returned HTTP $code"; exit 1 + fi + + artist-sync: + runs-on: ubuntu-latest + if: >- + github.repository == 'sona-fast/sona' && + (github.event_name == 'workflow_dispatch' || github.event.schedule == '30 6 * * *') + concurrency: + group: staging-artist-sync + cancel-in-progress: false + steps: + - name: Trigger artist-sync endpoint + env: + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$CRON_SECRET" ]; then + echo "::warning::CRON_SECRET not set — skipping staging artist sync (set the repo secret to opt in)"; exit 0 + fi + code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $CRON_SECRET" \ + "${STAGING_URL%/}/api/cron/sync-artists") + echo "HTTP $code"; cat response.json || true; echo + if [ "$code" = "503" ]; then + echo "Registry not configured on the site — skipping."; exit 0 + fi + if [ "$code" != "200" ]; then + echo "::error::Sync endpoint returned HTTP $code"; exit 1 + fi + + cleanup-orphans: + runs-on: ubuntu-latest + if: >- + github.repository == 'sona-fast/sona' && + (github.event_name == 'workflow_dispatch' || github.event.schedule == '0 7 * * 1') + concurrency: + group: staging-cleanup-orphans + cancel-in-progress: false + steps: + - name: Trigger cleanup-orphans endpoint + env: + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$CRON_SECRET" ]; then + echo "::warning::CRON_SECRET not set — skipping staging cleanup (set the repo secret to opt in)"; exit 0 + fi + code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $CRON_SECRET" \ + "${STAGING_URL%/}/api/cron/cleanup-orphans") + echo "HTTP $code"; cat response.json || true; echo + if [ "$code" = "503" ]; then + echo "Cron is not configured on the site (no CRON_SECRET Pages secret) — skipping."; exit 0 + fi + if [ "$code" != "200" ]; then + echo "::error::Cleanup endpoint returned HTTP $code"; exit 1 + fi + # A provider can SKIP itself (zero-keep safety belt) while the run stays + # HTTP 200. Surface that as a warning without failing the run. + skipped=$(jq -r '(.skipped // []) | join("; ")' response.json 2>/dev/null || true) + if [ -n "$skipped" ]; then + echo "::warning::Cleanup skipped a provider: $skipped" + fi diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml new file mode 100644 index 00000000..9f22b9a6 --- /dev/null +++ b/.github/workflows/staging-deploy.yml @@ -0,0 +1,257 @@ +name: Deploy to Cloudflare Pages (staging) + +# Staging deploy for the canonical repo ONLY. This is the exact inverse of +# deploy.yml's fork gate: deploy.yml runs on every fork and is gated OFF on +# sona-fast/sona; this one runs ONLY on sona-fast/sona and never on a fork. The +# two never fire on the same repo, so they can share the same repo vars/secrets +# without clobbering each other (on sona-fast/sona the vars point at the +# sona-staging project + DB; on a fork they point at that fork's own project). +# +# SECURITY — NEVER convert the trigger below to `pull_request_target`. This job +# holds CLOUDFLARE_API_TOKEN, SETUP_TOKEN, CRON_SECRET and more; pull_request_target +# runs with those secrets in the context of a PR's HEAD, which for an untrusted +# fork PR means arbitrary attacker-controlled code (build scripts, deps, tests) +# executes WITH our secrets and can exfiltrate them. Deploys must only ever run +# from trusted refs already merged to main. Keep the triggers `push` + manual. + +on: + push: + branches: [main] + # GitHub's "Sync fork" button fast-forwards main WITHOUT emitting a push + # event; this also gives a manual "Run workflow" button in the Actions tab. + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + # Inverse of deploy.yml: staging runs ONLY on the canonical repo. A fork + # never deploys to staging (it has its own deploy.yml gated the other way). + if: github.repository == 'sona-fast/sona' + permissions: + contents: read + deployments: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - run: npm run check + + - run: npm test + + - run: npm run build + + # Fail fast with a clear, actionable message if staging runs without + # Cloudflare credentials (rather than a cryptic wrangler error later). + - name: Verify Cloudflare credentials + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then + echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — add both in Settings → Secrets and variables → Actions to enable the staging deploy"; exit 1 + fi + + # Per-deployment names. On sona-fast/sona set repo variables + # CLOUDFLARE_PAGES_PROJECT=sona-staging and D1_DATABASE_NAME=sona-staging-db. + # The fallbacks default to the STAGING names (not the prod 'sona'/'sona-db') + # so a missing var can never point this pipeline at production. + - name: Create Pages project if needed + env: + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + curl -s -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${CLOUDFLARE_PAGES_PROJECT}\",\"production_branch\":\"main\"}" \ + | jq '.success // .errors' + + # Tracked migrations: a schema_migrations table records which files have run, + # so each is applied exactly once and a genuine SQL failure FAILS the deploy. + # Identical to deploy.yml — only the target DB (D1_DATABASE_NAME) differs. + - name: Run D1 migrations + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME || 'sona-staging-db' }} + run: | + set -euo pipefail + DB="$D1_DATABASE_NAME" + npx wrangler d1 execute "$DB" --remote --command \ + "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);" + applied=$(npx wrangler d1 execute "$DB" --remote --json \ + --command "SELECT name FROM schema_migrations;" | jq -r '.[0].results[].name' 2>/dev/null || true) + for f in drizzle/*.sql; do + name=$(basename "$f") + if printf '%s\n' "$applied" | grep -qxF "$name"; then + echo "✓ already applied: $name" + continue + fi + echo "→ applying: $name" + npx wrangler d1 execute "$DB" --remote --file="$f" + npx wrangler d1 execute "$DB" --remote --command \ + "INSERT INTO schema_migrations (name, applied_at) VALUES ('$name', datetime('now'));" + done + + # Optional feature: FurTrack photo integration. Set repo variable + # FURTRACK_MODE to `live` or `mock`. Per-key PATCH (nothing else clobbered); + # unset = logged skip. Identical to deploy.yml. + - name: Sync FURTRACK_MODE to Pages project + env: + FURTRACK_MODE: ${{ vars.FURTRACK_MODE }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -z "$FURTRACK_MODE" ]; then + echo "FURTRACK_MODE repo variable not set — skipping env var sync (FurTrack stays off)" + exit 0 + fi + payload=$(jq -n --arg v "$FURTRACK_MODE" \ + '{deployment_configs: {production: {env_vars: {FURTRACK_MODE: {type: "plain_text", value: $v}}}}}') + resp=$(curl -s -X PATCH \ + "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "$payload") + if ! echo "$resp" | jq -e '.success' >/dev/null; then + echo "::error::Failed to set FURTRACK_MODE on Pages project ${CLOUDFLARE_PAGES_PROJECT}" + echo "$resp" | jq '.errors' + exit 1 + fi + echo "FURTRACK_MODE=${FURTRACK_MODE} set on Pages project ${CLOUDFLARE_PAGES_PROJECT}" + + # Optional feature: observability (issue #6). Set repo variable + # OBSERVABILITY_ENABLED=true. Same per-key PATCH; unset = logged skip. + - name: Sync OBSERVABILITY_ENABLED to Pages project + env: + OBSERVABILITY_ENABLED: ${{ vars.OBSERVABILITY_ENABLED }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -z "$OBSERVABILITY_ENABLED" ]; then + echo "OBSERVABILITY_ENABLED repo variable not set — skipping env var sync (observability stays off)" + exit 0 + fi + payload=$(jq -n --arg v "$OBSERVABILITY_ENABLED" \ + '{deployment_configs: {production: {env_vars: {OBSERVABILITY_ENABLED: {type: "plain_text", value: $v}}}}}') + resp=$(curl -s -X PATCH \ + "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "$payload") + if ! echo "$resp" | jq -e '.success' >/dev/null; then + echo "::error::Failed to set OBSERVABILITY_ENABLED on Pages project ${CLOUDFLARE_PAGES_PROJECT}" + echo "$resp" | jq '.errors' + exit 1 + fi + echo "OBSERVABILITY_ENABLED=${OBSERVABILITY_ENABLED} set on Pages project ${CLOUDFLARE_PAGES_PROJECT}" + + # Optional feature: Telegram sticker import. Add repo secret + # TELEGRAM_BOT_TOKEN; synced to Pages here so this deploy binds it. Upsert + # (idempotent); unset = logged skip. Identical to deploy.yml. + - name: Sync TELEGRAM_BOT_TOKEN to Pages project + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -z "$TELEGRAM_BOT_TOKEN" ]; then + echo "TELEGRAM_BOT_TOKEN repo secret not set — skipping secret sync (Telegram stickers stay off)" + exit 0 + fi + printf '%s' "$TELEGRAM_BOT_TOKEN" | \ + npx wrangler pages secret put TELEGRAM_BOT_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "TELEGRAM_BOT_TOKEN synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)" + + # Optional feature: admin password recovery email. Add repo secrets + # RESEND_API_KEY (required to enable) and optionally RESEND_FROM. Unset = + # logged skip. Identical to deploy.yml. + - name: Sync RESEND secrets to Pages project + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_FROM: ${{ secrets.RESEND_FROM }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -z "$RESEND_API_KEY" ]; then + echo "RESEND_API_KEY repo secret not set — skipping secret sync (email password reset stays off; use npm run reset-password)" + exit 0 + fi + printf '%s' "$RESEND_API_KEY" | \ + npx wrangler pages secret put RESEND_API_KEY --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "RESEND_API_KEY synced to Pages project ${CLOUDFLARE_PAGES_PROJECT}" + if [ -n "$RESEND_FROM" ]; then + printf '%s' "$RESEND_FROM" | \ + npx wrangler pages secret put RESEND_FROM --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "RESEND_FROM synced to Pages project ${CLOUDFLARE_PAGES_PROJECT}" + fi + + # Bootstrap + cron secrets. Pages secrets bind at DEPLOY TIME, so re-put + # SETUP_TOKEN (first-run wizard gate) and CRON_SECRET (cron endpoint auth) + # right before the deploy so THIS deployment binds them. Idempotent upsert; + # unset = logged skip. Identical to deploy.yml. + - name: Sync SETUP_TOKEN + CRON_SECRET to Pages project + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + SETUP_TOKEN: ${{ secrets.SETUP_TOKEN }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -n "$SETUP_TOKEN" ]; then + printf '%s' "$SETUP_TOKEN" | \ + npx wrangler pages secret put SETUP_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "SETUP_TOKEN synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)" + else + echo "SETUP_TOKEN repo secret not set — skipping (wizard already completed, or not wired)" + fi + if [ -n "$CRON_SECRET" ]; then + printf '%s' "$CRON_SECRET" | \ + npx wrangler pages secret put CRON_SECRET --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "CRON_SECRET synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)" + else + echo "CRON_SECRET repo secret not set — skipping" + fi + + # Optional feature: the Cloudflare "edge traffic" panel on + # /admin/observability. Add repo secrets CLOUDFLARE_ANALYTICS_TOKEN and + # CLOUDFLARE_ZONE_ID. All three (incl. the account id, only a deploy secret + # otherwise) are put so the panel has them at runtime. Unset = logged skip. + - name: Sync Cloudflare analytics secrets to Pages project + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ANALYTICS_TOKEN: ${{ secrets.CLOUDFLARE_ANALYTICS_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} + run: | + set -euo pipefail + if [ -z "$CLOUDFLARE_ANALYTICS_TOKEN" ] || [ -z "$CLOUDFLARE_ZONE_ID" ]; then + echo "CLOUDFLARE_ANALYTICS_TOKEN / CLOUDFLARE_ZONE_ID repo secrets not both set — skipping (Cloudflare edge panel stays hidden)" + exit 0 + fi + printf '%s' "$CLOUDFLARE_ANALYTICS_TOKEN" | \ + npx wrangler pages secret put CLOUDFLARE_ANALYTICS_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT" + printf '%s' "$CLOUDFLARE_ZONE_ID" | \ + npx wrangler pages secret put CLOUDFLARE_ZONE_ID --project-name "$CLOUDFLARE_PAGES_PROJECT" + printf '%s' "$CLOUDFLARE_ACCOUNT_ID" | \ + npx wrangler pages secret put CLOUDFLARE_ACCOUNT_ID --project-name "$CLOUDFLARE_PAGES_PROJECT" + echo "Cloudflare analytics secrets synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (bind on the deploy below)" + + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy .svelte-kit/cloudflare --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }} diff --git a/docs/staging.md b/docs/staging.md new file mode 100644 index 00000000..9a76f5a8 --- /dev/null +++ b/docs/staging.md @@ -0,0 +1,139 @@ +# Staging environment (beta.sona.fast) + +One staging deployment of the canonical `sona-fast/sona` repo, for testing changes +against real Cloudflare infrastructure before they reach a fork. It lives at +**https://beta.sona.fast** behind Cloudflare Access (invite-only beta testers). + +- Deploy pipeline: [`.github/workflows/staging-deploy.yml`](../.github/workflows/staging-deploy.yml) + — runs on push to `main` (and manual dispatch), gated `if: github.repository == 'sona-fast/sona'` + (the exact inverse of `deploy.yml`'s fork gate, so the two never both fire on one repo). +- Cron: [`.github/workflows/staging-cron.yml`](../.github/workflows/staging-cron.yml) + — sticker re-sync (daily), artist sync (daily), orphan cleanup (weekly). +- Seed: [`scripts/staging-seed.sql`](../scripts/staging-seed.sql) + [`scripts/hash-admin-password.ts`](../scripts/hash-admin-password.ts). + +The Pages project is `sona-staging`; the D1 DB is `sona-staging-db`; the R2 bucket +is `sona-staging-images`. + +> **Do NOT** convert `staging-deploy.yml` to `pull_request_target`. It holds +> `CLOUDFLARE_API_TOKEN`, `SETUP_TOKEN`, `CRON_SECRET`; that trigger would run an +> untrusted fork PR's code with those secrets in scope. See the comment at the top +> of the workflow. + +--- + +## One-time setup + +Do these **in order**. The Access policy + app (step 3) must exist **before the +first deploy** (step 6) so the site is never briefly public. + +### Manual — needs the account owner's Cloudflare token / dashboard + +These require account-level credentials and can't be scripted from CI. + +**1. Repo secrets & variables** (`sona-fast/sona` → Settings → Secrets and variables → Actions) + +Variables: + +| Variable | Value | +| --- | --- | +| `CLOUDFLARE_PAGES_PROJECT` | `sona-staging` | +| `D1_DATABASE_NAME` | `sona-staging-db` | +| `OBSERVABILITY_ENABLED` | `true` (optional — exercise the metrics dashboard) | + +Secrets (same names `deploy.yml` consumes, so the shared steps work unmodified). +GitHub Actions secrets are **per repository**: production forks read the values +set in their own repos, and nothing here is visible to them. On this repo the +fork-facing workflows (`deploy.yml`, the cron workflows) are gated off, so these +values feed the staging workflows only: + +| Secret | Notes | +| --- | --- | +| `CLOUDFLARE_API_TOKEN` | required | +| `CLOUDFLARE_ACCOUNT_ID` | required | +| `SETUP_TOKEN` | required for the first-run wizard; re-bound on every deploy | +| `CRON_SECRET` | required for `staging-cron.yml` to authenticate to the endpoints | +| `TELEGRAM_BOT_TOKEN` / `RESEND_API_KEY` / `RESEND_FROM` / `CLOUDFLARE_ANALYTICS_TOKEN` / `CLOUDFLARE_ZONE_ID` | optional; each sync step skips cleanly when its secret is unset | + +**2. Create the D1 DB + R2 bucket** (once): + +```sh +npx wrangler d1 create sona-staging-db +npx wrangler r2 bucket create sona-staging-images +``` + +**3. Cloudflare Access — beta testers policy + app (BEFORE the first deploy)** + +Use a **new reusable policy named "Sona beta testers"** — NOT the existing "Sona +team Allowlist" (owner's decision: beta testers are a distinct group). Create it +account-level, then attach it to a **new Access application** whose domains cover +**both**: + +- `beta.sona.fast` +- `sona-staging.pages.dev` (the raw Pages URL — otherwise the deployment is + reachable un-gated before the custom domain is attached) + +Sequencing matters: the app + policy must be live before step 6 so `sona-staging` +is never served publicly. (Reusable policies are created at the account-level +`/policies` endpoint — see the `sona-fast-allowlist` skill for the exact call shape.) + +**4. DNS + custom domain** + +- Add a DNS record for `beta.sona.fast` (proxied) in the `sona.fast` zone. +- Attach `beta.sona.fast` as a custom domain on the `sona-staging` Pages project. + +> This custom-domain attach is exactly the "Pages domain" rung of the SONA-6 +> connect-domains / doctor tooling (in flight on `feat/connect-domains-doctor-11`). +> Staging doubles as that tooling's first live test bed: point it at `beta.sona.fast` +> + `sona-staging` and it should detect/attach the domain end to end. + +**5. Pages project binding config** (once, in the dashboard or via `wrangler`) + +The `#115` lesson: a CI-first Pages project has **no** bindings until you set them, +and a deploy without them 500s. Configure on `sona-staging`: + +- D1 binding `DB` → `sona-staging-db` +- R2 binding `IMAGES` → `sona-staging-images` +- Compatibility flag `nodejs_compat` +- A `compatibility_date` (match the repo's `wrangler.toml`) + +### Scriptable — after the manual prep + +**6. First deploy** + +Push to `main` (or run the **Deploy to Cloudflare Pages (staging)** workflow +manually from the Actions tab). It runs check/test/build, creates the Pages +project if needed, applies tracked D1 migrations, syncs secrets, and deploys. + +**7. Seed generation + load** + +`staging-seed.sql` ships a **placeholder** admin hash — generate a real one for a +password you choose, substitute it, and load the seed into the remote DB: + +```sh +# 1. Generate a hash (password read from stdin, never argv/history): +HASH=$(echo -n 'your-staging-password' | npx tsx scripts/hash-admin-password.ts) + +# 2. Substitute the placeholder and load into the remote staging DB: +sed "s#REPLACE_ME_WITH_pbkdf2_HASH#${HASH}#" scripts/staging-seed.sql \ + | npx wrangler d1 execute sona-staging-db --remote --file=/dev/stdin +``` + +Then sign in at `https://beta.sona.fast/admin/login` with that password. The seed +is synthetic (a fake artist + placeholder images that 404 harmlessly); never bake +a real password's hash into the repo. + +--- + +## Resetting for wizard testing + +To re-test the first-run wizard from scratch, clear the credential + setup flag so +`hooks.server.ts` redirects back to `/admin/setup`: + +```sh +npx wrangler d1 execute sona-staging-db --remote --command \ + "DELETE FROM site_settings WHERE key IN ('setupComplete','adminPasswordHash'); DELETE FROM sessions;" +``` + +The wizard then requires the `SETUP_TOKEN` (repo secret, re-bound each deploy). +Re-run steps 6–7 (or just the wizard in the browser) to bring it back up. To wipe +data too, re-create the DB (step 2) and re-run migrations via a deploy. diff --git a/scripts/hash-admin-password.ts b/scripts/hash-admin-password.ts new file mode 100644 index 00000000..3f7c6cf1 --- /dev/null +++ b/scripts/hash-admin-password.ts @@ -0,0 +1,36 @@ +/** + * Print a PBKDF2 admin-password hash for the staging seed. + * + * echo -n 'your-staging-password' | npx tsx scripts/hash-admin-password.ts + * + * Reads the password from stdin (so it never lands in shell history or argv) and + * writes the encoded `pbkdf2$sha256$...` hash to stdout — the same format the + * app's verifyPasswordHash() accepts. Substitute the printed value for the + * REPLACE_ME_WITH_pbkdf2_HASH placeholder in scripts/staging-seed.sql before + * loading the seed (see docs/staging.md). Reuses reset-password.ts's hasher so + * there is exactly one PBKDF2 implementation for the CLI tooling. + */ +import { fileURLToPath } from 'node:url'; +import { hashPasswordPbkdf2 } from './reset-password'; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +async function main() { + // Trim only the trailing newline `echo`/a heredoc adds, not interior chars. + const password = (await readStdin()).replace(/\r?\n$/, ''); + if (!password) { + throw new Error('No password on stdin. Usage: echo -n | npx tsx scripts/hash-admin-password.ts'); + } + process.stdout.write((await hashPasswordPbkdf2(password)) + '\n'); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(`\n✖ ${err instanceof Error ? err.message : err}`); + process.exit(1); + }); +} diff --git a/scripts/staging-seed.sql b/scripts/staging-seed.sql new file mode 100644 index 00000000..422b5e4b --- /dev/null +++ b/scripts/staging-seed.sql @@ -0,0 +1,56 @@ +-- Staging seed. Synthetic data only — load into the sona-staging D1 AFTER the +-- migrations run (see docs/staging.md). Adapted from tests/e2e/fixtures/seed.sql. +-- Everything here is fake: a placeholder artist, same-origin placeholder image +-- URLs (they 404 harmlessly, so no external network calls), and a marked +-- admin-hash placeholder the operator MUST replace before the DB is usable. + +-- Site is past first-run setup so hooks.server.ts serves normal routes instead +-- of redirecting everything to /admin/setup. +INSERT OR REPLACE INTO site_settings (key, value) VALUES + ('setupComplete', 'true'), + ('siteName', 'Sona Staging'), + ('ownerName', 'Staging'), + ('adminEmail', 'staging@example.invalid'); + +-- Admin login credential. This is a PLACEHOLDER, not a working hash — logging in +-- with it is impossible until you replace it. Generate a real hash for a password +-- you choose and substitute it (see docs/staging.md "Seed generation + load"): +-- +-- echo -n 'your-staging-password' | npx tsx scripts/hash-admin-password.ts +-- +-- then replace the value below with the printed pbkdf2$... string (or sed it in +-- at load time). Never commit a real password's hash to the repo. +-- +-- WARNING: loading this seed WITHOUT the substitution is a silent admin lockout — +-- setupComplete=true skips the wizard, but no password verifies against the +-- placeholder, so /admin/login rejects every attempt. Recover with +-- `npm run reset-password` or the wizard-reset block in docs/staging.md. +INSERT OR REPLACE INTO site_settings (key, value) VALUES + ('adminPasswordHash', 'REPLACE_ME_WITH_pbkdf2_HASH'); + +-- One synthetic artist to credit the placeholder images. +INSERT OR REPLACE INTO artists (id, name, created_at) +VALUES (1, 'Staging Artist', '2026-07-01T00:00:00.000Z'); + +-- A published SFW parent + a published NSFW variant pointing at it, mirroring the +-- e2e fixture so the gallery/variant surfaces have something to render. URLs are +-- same-origin placeholders that 404 harmlessly. +INSERT OR REPLACE INTO images + (id, title, slug, image_url, thumbnail_url, width, height, nsfw, published, artist_id, parent_image_id, variant_label, created_at) +VALUES + (1, 'Parent Piece SFW', 'parent-piece', + '/staging/parentpiece.png', '/staging/parentpiece-thumb.png', + 900, 700, 0, 1, 1, NULL, NULL, '2026-07-01T00:00:00.000Z'), + (2, 'Variant Piece NSFW', 'variant-piece', + '/staging/variantpiece.png', '/staging/variantpiece-thumb.png', + 900, 700, 1, 1, 1, 1, 'Alt', '2026-07-02T00:00:00.000Z'); + +-- A published reference sheet (tagged 'reference') for the admin palette-picker. +INSERT OR REPLACE INTO images + (id, title, slug, image_url, thumbnail_url, width, height, nsfw, published, artist_id, parent_image_id, variant_label, created_at) +VALUES + (3, 'Ref Sheet', 'ref-sheet', + '/staging/refsheet.png', '/staging/refsheet-thumb.png', + 1200, 900, 0, 1, 1, NULL, NULL, '2026-07-03T00:00:00.000Z'); +INSERT OR REPLACE INTO tags (id, name, created_at) VALUES (1, 'reference', '2026-07-01T00:00:00.000Z'); +INSERT OR REPLACE INTO image_tags (image_id, tag_id) VALUES (3, 1); diff --git a/scripts/staging-seed.test.ts b/scripts/staging-seed.test.ts new file mode 100644 index 00000000..3546655d --- /dev/null +++ b/scripts/staging-seed.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +// better-sqlite3 ships no bundled types and is a dev-only test dependency here. +// @ts-expect-error - no declaration file for 'better-sqlite3' +import Database from 'better-sqlite3'; +import { verifyPasswordHash } from '../src/lib/server/admin-auth'; +import { hashPasswordPbkdf2 } from './reset-password'; + +const drizzleDir = new URL('../drizzle/', import.meta.url); +const seedUrl = new URL('./staging-seed.sql', import.meta.url); + +/** Apply every drizzle migration in order to a fresh in-memory DB. */ +function migratedDb() { + const sqlite = new Database(':memory:'); + const files = readdirSync(drizzleDir) + .filter((f) => f.endsWith('.sql')) + .sort(); + for (const name of files) { + const sql = readFileSync(new URL(name, drizzleDir), 'utf8'); + for (const stmt of sql.split('--> statement-breakpoint')) sqlite.exec(stmt); + } + return sqlite; +} + +describe('staging-seed.sql', () => { + it('applies cleanly to a fresh DB after all migrations', () => { + const sqlite = migratedDb(); + const seed = readFileSync(seedUrl, 'utf8'); + expect(() => sqlite.exec(seed)).not.toThrow(); + + // Past first-run setup, so hooks.server.ts won't force the wizard. + const setup = sqlite + .prepare("SELECT value FROM site_settings WHERE key = 'setupComplete'") + .get() as { value: string } | undefined; + expect(setup?.value).toBe('true'); + + // The synthetic artist + its three published images seeded. + const artists = sqlite.prepare('SELECT COUNT(*) AS n FROM artists').get() as { n: number }; + expect(artists.n).toBe(1); + const images = sqlite.prepare('SELECT COUNT(*) AS n FROM images WHERE published = 1').get() as { n: number }; + expect(images.n).toBe(3); + }); + + it('ships a placeholder admin hash, never a working credential', () => { + const seed = readFileSync(seedUrl, 'utf8'); + // The committed seed must not carry a real, verifiable hash. + expect(seed).toContain('REPLACE_ME_WITH_pbkdf2_HASH'); + expect(seed).not.toMatch(/pbkdf2\$sha256\$\d+\$/); + }); +}); + +describe('hash-admin-password — seed hash slot round-trips', () => { + it('a hash generated for the seed verifies against the app once substituted', async () => { + // hash-admin-password.ts prints exactly this value; substituting it for the + // placeholder yields an adminPasswordHash the app's verifier accepts. + const hash = await hashPasswordPbkdf2('staging-admin-pw'); + expect(hash).toMatch(/^pbkdf2\$sha256\$100000\$[^$]+\$[^$]+$/); + + const sqlite = migratedDb(); + const seed = readFileSync(seedUrl, 'utf8').replace('REPLACE_ME_WITH_pbkdf2_HASH', hash); + sqlite.exec(seed); + const stored = sqlite + .prepare("SELECT value FROM site_settings WHERE key = 'adminPasswordHash'") + .get() as { value: string }; + expect(await verifyPasswordHash('staging-admin-pw', stored.value)).toBe(true); + expect(await verifyPasswordHash('wrong-pw', stored.value)).toBe(false); + }); +});