From 5b0076cb2048f34eb9b249fe1bbb597c3c0d402e Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 19 Sep 2026 00:20:11 -0400 Subject: [PATCH] fix: fail closed backend capabilities and isolate gate smoke --- .gitea/workflows/ci.yml | 23 ++-- docs/release-safety.md | 38 +++++-- docs/restore-rehearsal.md | 156 ++++++++++++++++++++++---- package.json | 3 +- scripts/release-gate-smoke.ts | 58 +++++++--- scripts/smoke-restore-marker.ts | 39 +++++++ src/convex/functions.ts | 4 +- src/convex/status.ts | 4 +- src/lib/server/release-gate.ts | 6 + src/routes/+layout.server.ts | 11 +- src/routes/api/chat/+server.ts | 14 +-- src/routes/api/sync/+server.ts | 7 +- src/routes/assets/+page.server.ts | 12 +- src/routes/characters/+page.server.ts | 20 ++-- tests/convex.test.ts | 19 ++++ tests/release-safety.test.ts | 3 + tests/workflow-config.test.ts | 8 +- 17 files changed, 344 insertions(+), 81 deletions(-) create mode 100644 scripts/smoke-restore-marker.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 42b4042..c11516d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,10 +37,10 @@ jobs: bun run release:check container: - # Docker builds run only for trusted pushes. Do not run this job for PR - # code on a shared/privileged runner; use a dedicated rootless runner once - # one is registered and verified in Gitea. - if: ${{ gitea.event_name == 'push' }} + # HOLD: no verified dedicated/rootless runner or protected-ref policy has + # been confirmed in Gitea. Keep this disabled rather than treating every + # push as trusted; enable only after operator approval and runner evidence. + if: ${{ false }} runs-on: ubuntu-latest steps: - name: Checkout @@ -48,22 +48,27 @@ jobs: - name: Validate Compose expansion run: docker compose config --quiet - name: Build immutable web artifact - run: docker build --build-arg APP_RELEASE_ID=fixture-ci --tag eve-intelligence:fixture . + run: >- + docker build --build-arg APP_RELEASE_ID=fixture-ci + --tag "eve-intelligence:fixture-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:-1}" . - name: Run isolated container smoke shell: bash run: | set -euo pipefail - volume="eden-ci-reference-${GITHUB_RUN_ID:-trusted-push}" - container="eden-ci-web-${GITHUB_RUN_ID:-trusted-push}" + run_id="${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" + run_attempt="${GITHUB_RUN_ATTEMPT:-1}" + image="eve-intelligence:fixture-${run_id}-${run_attempt}" + volume="eden-ci-reference-${run_id}-${run_attempt}" + container="eden-ci-web-${run_id}-${run_attempt}" cleanup() { docker rm --force "$container" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null 2>&1 || true; } trap cleanup EXIT docker volume create "$volume" >/dev/null - docker run --rm --network none --user root -v "$volume:/app/data/reference" eve-intelligence:fixture \ + docker run --rm --network none --user root -v "$volume:/app/data/reference" "$image" \ bun workers/reference.ts --fixture --directory /app/data/reference docker run --detach --name "$container" --network none \ -e APP_MODE=demo -e APP_ORIGIN=http://127.0.0.1:3000 -e ORIGIN=http://127.0.0.1:3000 \ -e HOST=127.0.0.1 -e PORT=3000 -e REFERENCE_DIR=/app/data/reference -e APP_RELEASE_ID=fixture-ci \ - -v "$volume:/app/data/reference:ro" eve-intelligence:fixture >/dev/null + -v "$volume:/app/data/reference:ro" "$image" >/dev/null for attempt in $(seq 1 60); do if docker exec "$container" bun -e "const r = await fetch('http://127.0.0.1:3000'); if (r.status !== 200 || r.headers.get('x-application-release') !== 'fixture-ci') process.exit(1);"; then break; fi if [ "$attempt" = 60 ]; then echo 'container smoke did not become ready' >&2; exit 1; fi diff --git a/docs/release-safety.md b/docs/release-safety.md index a6cd8f3..486704b 100644 --- a/docs/release-safety.md +++ b/docs/release-safety.md @@ -40,6 +40,25 @@ provider. The current character status subscription is gated in Convex; no durable user-job service exists in this baseline, so future job kinds remain closed until they have reviewed enqueue and worker entry points. +### Policy authority and update order + +The web and Convex processes receive the same value, but parse it in separate +processes. The web parser preserves the private baseline when its setting is +omitted for development compatibility; the Convex/backend parser requires an +explicit, valid setting and fails closed when propagation is missing, empty, +or malformed. Convex is authoritative for persisted subscriptions and bridge +mutations; the web gate is an earlier ingress/provider guard, not a replacement +for backend authorization. + +Update the backend setting first, validate a status query and representative +bridge mutation in an isolated deployment, then update the web setting and +verify the HTTP gate. A web-only narrowing does **not** revoke existing +Convex subscriptions, sessions, or grants, and this lane makes no such claim. +A backend-only narrowing may cause already-running web requests to receive +backend denials until the web setting converges. Record both settings and the +validation timestamps in the release evidence; rollback must restore the +backend policy before reopening web entry points. + ## Immutable release metadata `APP_RELEASE_ID` is carried into the runtime image and returned as the @@ -89,15 +108,16 @@ lane. ## Evidence boundaries Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs -for `jb/eden`; runner labels, Docker availability, and action availability are -therefore not validated. The workflow is prepared locally but has not run -remotely. The source job runs a strict staging release check after building -the client. The separate container job is restricted to trusted pushes, -validates Compose expansion, builds the digest-pinned web image, compiles a -synthetic reference into a private volume, and smoke-tests the runtime with -`--network none`; these steps still need an actual compatible Gitea runner. -No browser automation, OAuth callback failure injection, Convex migration -rollback, instance-credential backup/restore, key rotation, or live +for `jb/eden`; runner labels, Docker availability, protected-ref enforcement, +and action availability were therefore not validated. The workflow is prepared +locally but has not run remotely. The source job runs a strict staging release +check after building the client. The separate container job is currently +**disabled** (`if: ${{ false }}`), not merely push-filtered: its rootless +runner and protected-ref policy require operator verification and approval +before enablement. Its draft commands use per-run image, volume, and container +names and clean up only those names, but are not release evidence while the job +is disabled. No browser automation, OAuth callback failure injection, Convex +migration rollback, instance-credential backup/restore, key rotation, or live revocation/transfer evidence is claimed by this lane. Those remain explicit operator/integration gates and must use synthetic or separately consented isolated environments. diff --git a/docs/restore-rehearsal.md b/docs/restore-rehearsal.md index 6b90e02..7eebd40 100644 --- a/docs/restore-rehearsal.md +++ b/docs/restore-rehearsal.md @@ -20,11 +20,13 @@ are explicit blockers below, not steps that may be silently skipped. ## Prerequisites and isolation - A clean T01 worktree at a recorded commit; Bun 1.3.14 and Docker/Compose. -- A unique project name, for example `eden-restore-$USER-$RANDOM`, and unique - volume names. Keep published ports on loopback and use a disposable backend. -- At least **2x the current Convex data volume bytes plus 10%** free disk for - source volume, backup archive, and restored volume. Record `du -sb` before - starting. +- A unique project name, for example `eden-restore-$USER-$RANDOM`, unique + volume names, and unique loopback ports. Keep all published ports on + loopback and use a disposable backend. +- Free disk equal to **source volume bytes + backup archive bytes + restored + volume bytes + 20% headroom**. The archive size is unknown until backup, so + record `du -sb` before starting and recheck before extraction; a simple 2x + estimate is not sufficient. - Budget **2 CPUs, 4 GiB RAM, and 30 minutes** for the disposable backend and application rehearsal. A reference rebuild is a separate T03 operation; the reviewed production worker budget is 2 CPUs/4 GiB and must not be @@ -32,14 +34,28 @@ are explicit blockers below, not steps that may be silently skipped. - A temporary mode-0600 directory outside the repository for synthetic keys, backup archives, logs, and evidence. Do not print key values or put them in command arguments. +- Host root access for direct Docker-volume `tar` operations, or a separately + approved, digest-pinned helper image. The commands below use direct host + tar and must be run only on the disposable volumes. -```sh +```bash +#!/usr/bin/env bash set -euo pipefail repo="$PWD" project="eden-restore-${USER:-operator}-$(date +%s)-$$" +restore_project="${project}-restored" work="$(mktemp -d "${TMPDIR:-/tmp}/eden-restore.XXXXXX")" chmod 700 "$work" -trap 'docker compose -p "$project" down -v >/dev/null 2>&1 || true; rm -rf "$work"' EXIT +export CONVEX_BIND_ADDRESS=127.0.0.1 CONVEX_PORT=$((3200 + RANDOM % 500)) CONVEX_SITE_PORT=$((3700 + RANDOM % 500)) +export WEB_BIND_ADDRESS=127.0.0.1 WEB_PORT=$((4200 + RANDOM % 500)) +cleanup() { + docker compose -p "$project" down -v >/dev/null 2>&1 || true + docker compose -p "$restore_project" down -v >/dev/null 2>&1 || true + # Export/copy the evidence bundle before this cleanup; the temporary key and + # archives are intentionally removed only after retention is recorded. + rm -rf "$work" +} +trap cleanup EXIT (cd "$work" && bun "$repo/scripts/generate-keys.ts") chmod 600 "$work/.env.keys" ``` @@ -60,13 +76,27 @@ callback or provider key. docker compose -p "$project" up -d backend volume="${project}_convex-data" mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$volume")" - du -sb "$mount" + source_bytes_before="$(du -sb "$mount" | awk '{print $1}')" + printf 'source_bytes_before=%s\n' "$source_bytes_before" > "$work/volume-metrics.txt" ``` -2. Configure the synthetic Convex environment using the temporary key file, - synthetic issuer/JWKS, bridge secret, and allowlist. Use the existing - `bun run convex:configure` path only with the disposable URL/admin key; - never pass a persistent admin key through this procedure. +2. Obtain the disposable instance admin key through the operator-approved + secret mechanism for the exact pinned `CONVEX_IMAGE`; hold it in memory + only. Configure the synthetic Convex environment using the temporary key + file, synthetic issuer/JWKS, bridge secret, and allowlist. Do not invent a + key fingerprint or assume an image-specific helper command: + + ```sh + read -r -s CONVEX_SELF_HOSTED_ADMIN_KEY + export CONVEX_SELF_HOSTED_ADMIN_KEY + CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \ + bun "$repo/scripts/configure-convex.ts" >/dev/null + ``` + + Use the existing configure path only with this disposable URL/admin key; + never pass a persistent admin key through this procedure. Successful + configuration against the restored instance using this same in-memory key + is the credential continuity check. 3. Run the existing synthetic write smoke against this backend, with `SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It @@ -74,6 +104,19 @@ callback or provider key. the previous generation, tenant isolation, JWT audience checks, and local revocation. + Then write a deterministic marker that can be looked up after restore. Use + a fresh marker and synthetic character ID for every rehearsal; do not use a + random-only smoke result as proof of record survival: + + ```sh + export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic" + export RESTORE_MARKER_CHARACTER_ID=998000001 + SMOKE_ALLOW_WRITE=1 RESTORE_MARKER="$RESTORE_MARKER" \ + RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ + CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \ + bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log" + ``` + 4. Create a consistent filesystem backup while the disposable backend is stopped. The Convex data volume contains the backend data and instance credential material; back up it as one unit. A host administrator may need @@ -84,13 +127,17 @@ callback or provider key. tar --xattrs --acls --numeric-owner -C "$mount" \ -czf "$work/convex-data.tar.gz" . sha256sum "$work/convex-data.tar.gz" > "$work/convex-data.tar.gz.sha256" + archive_bytes="$(stat -c '%s' "$work/convex-data.tar.gz")" + printf 'archive_bytes=%s\n' "$archive_bytes" >> "$work/volume-metrics.txt" cp --preserve=mode "$work/.env.keys" "$work/application-keys.synthetic" sha256sum "$work/application-keys.synthetic" > "$work/application-keys.synthetic.sha256" ``` Record archive byte size, SHA-256, volume size, and stop/start timestamps. Do not include raw keys in the evidence bundle; retain the mode-0600 file in - the protected temporary directory until cleanup. + the protected temporary directory until cleanup. Before extraction, require + free disk of at least `source_bytes_before + archive_bytes + estimated + restored_bytes + 20% headroom`. ## Restore and isolation checks @@ -98,38 +145,59 @@ callback or provider key. recorded archive into the new volume, preserving ownership and xattrs. ```sh - restore_project="${project}-restored" restore_volume="${restore_project}_convex-data" docker volume create "$restore_volume" >/dev/null restore_mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$restore_volume")" tar --xattrs --acls --numeric-owner -C "$restore_mount" \ -xzf "$work/convex-data.tar.gz" + export CONVEX_PORT=$((5200 + RANDOM % 500)) CONVEX_SITE_PORT=$((5700 + RANDOM % 500)) WEB_PORT=$((6200 + RANDOM % 500)) ``` 2. Start the restored backend under the second project and configure the web - container with **only** the synthetic application keys. Confirm that the - restored instance credential is the backed-up credential, not a newly - generated one; record only a redacted fingerprint/metadata, never its - value. + container with **only** the synthetic application keys. Reuse the same + in-memory `CONVEX_SELF_HOSTED_ADMIN_KEY` captured before backup; the + configure command must succeed against the restored URL. Failure means + instance-credential continuity is unproven and traffic must remain closed: -3. Repeat the synthetic Convex smoke with fresh synthetic IDs. Verify: + ```sh + docker compose -p "$restore_project" up -d backend + CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \ + bun "$repo/scripts/configure-convex.ts" >/dev/null + ``` + +3. Verify the deterministic marker before running any fresh smoke, then run + the synthetic Convex smoke with fresh IDs: + + ```sh + SMOKE_ALLOW_WRITE=1 RESTORE_MARKER="$RESTORE_MARKER" \ + RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ + CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \ + RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts" + ``` + + Verify: - restored schema/functions start without a migration error; - a newly created synthetic user cannot read the source project's records; - restored records remain tenant-isolated; - - the restored reference/config volume is separate and mounted read-only by - web; + - the deterministic pre-backup marker is present and unchanged; - the old pre-backup session/JWT is rejected after the approved invalidation step; and - the old encrypted grant cannot service synchronization until explicit reauthorization. + Reference-volume separation, read-only web mounting, and reference artifact + rollback are outside this procedure and belong to T03's separate rehearsal. + Do not record those as T01 restore evidence. + 4. **Current blocker:** the repository does not yet provide a global post-restore session/grant invalidation command. Do not claim this check passed. The operator must choose and approve one of: a Convex migration that revokes all sessions and quarantines token records, a deployment-specific credential-version fence, or an equivalent bounded mechanism. Record the maximum stale-authority window and the decision before reopening traffic. + Grant rotation/quarantine behavior is unexecuted until that mechanism + exists; no synthetic provider result can substitute for it. 5. Test application rollback by stopping the restored web service, starting the immediately previous digest-pinned web image against the restored backend, @@ -141,21 +209,61 @@ callback or provider key. There is no migration history/rollback command in this baseline. A valid rehearsal therefore requires a parent-approved migration fixture and a chosen -rollback mechanism. At minimum: +rollback mechanism. Before starting, record the exact **baseline commit/image +and candidate commit/image**, the migration command, the backend image digest, +and the compatibility contract. The evidence must include commands equivalent +to these (with the operator's approved migration tool substituted explicitly): + +```sh +baseline_commit=$(git rev-parse HEAD) +candidate_commit= +baseline_image= +candidate_image= +# approved migration command against the disposable candidate deployment: + +# restore pre-migration archive into a fresh volume/project: + +# start baseline image against the restored volume: + +``` + +At minimum: 1. back up the disposable volume before migration; -2. deploy the migration to the disposable backend; +2. deploy the candidate migration to the disposable backend; 3. run schema/functions and tenant-isolation checks; 4. restore the pre-migration volume into a fresh project (never in place); 5. start the previous application image and rerun the checks; and 6. record whether the migration is backward compatible and which data loss, RPO, and RTO limits apply. -A `docker compose down -v` or a successful process restart is not migration +Placeholder commands are an evidence gap, not a passing rehearsal. A +`docker compose down -v` or a successful process restart is not migration rollback evidence. T03's reference artifact rollback is a separate test and must verify profile, importer version, manifest integrity, and live-reader compatibility. +## Retention and cleanup + +Before leaving the shell (and before the `trap` removes `$work`), export only +redacted logs, checksums, metrics, image digests, and the evidence table to a +protected location outside the temporary directory. Never copy either key +file or an admin key into that bundle: + +```sh +evidence_dir="${EVIDENCE_DIR:?Set an approved protected evidence directory}" +umask 077 +mkdir -p "$evidence_dir/$project" +cp "$work"/*.log "$work"/*.sha256 "$work"/*metrics* "$evidence_dir/$project/" 2>/dev/null || true +printf 'source_project=%s\nrestored_project=%s\n' "$project" "$restore_project" > "$evidence_dir/$project/projects.txt" +sha256sum "$evidence_dir/$project"/* > "$evidence_dir/$project/evidence.sha256" +printf 'retained_until_utc=\n' > "$evidence_dir/$project/retention.txt" +``` + +Confirm the bundle contains no `.env`, `application-keys`, admin key, token, +or unredacted provider output. Only after export and operator approval may the +cleanup trap remove both Compose projects, both volumes, and `$work`. + ## Evidence template Copy this table into the release record and attach only redacted logs/checksums: diff --git a/package.json b/package.json index cc37d35..8cf8cd6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "secrets:run": "bun scripts/secrets.ts run", "release:check": "bun scripts/release-check.ts", "test:fixture": "bun scripts/fixture-smoke.ts", - "test:release-gates": "bun scripts/release-gate-smoke.ts" + "test:release-gates": "bun scripts/release-gate-smoke.ts", + "smoke:restore-marker": "bun scripts/smoke-restore-marker.ts" }, "dependencies": { "@sveltejs/kit": "2.70.3", diff --git a/scripts/release-gate-smoke.ts b/scripts/release-gate-smoke.ts index cee7d88..d424ef9 100644 --- a/scripts/release-gate-smoke.ts +++ b/scripts/release-gate-smoke.ts @@ -21,23 +21,24 @@ const base = `http://127.0.0.1:${port}`; const releaseId = `gate-${crypto.randomUUID()}`; const referenceDir = await mkdtemp(join(tmpdir(), 'eden-gate-reference-')); let server: Bun.Subprocess | undefined; +let trap: ReturnType | undefined; async function run(args: string[], env: Record): Promise { const child = Bun.spawn(args, { env: { ...safeEnv, ...env }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }); if (await child.exited !== 0) throw new Error(`Command failed: ${args.join(' ')}`); } -async function waitForServer(): Promise { +async function waitForServer(baseUrl: string, expectedReleaseId: string): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { try { - const response = await fetch(base); - if (response.status === 200 && response.headers.get('x-application-release') === releaseId) return; + const response = await fetch(baseUrl); + if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return; } catch { /* startup race */ } await Bun.sleep(250); } throw new Error('Rollout-gate smoke server did not become ready'); } -function assertStatus(response: Response, expected: number, label: string): void { - if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}`); +async function assertStatus(response: Response, expected: number, label: string): Promise { + if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}; ${await response.clone().text()}`); } try { await run(['bun', 'workers/reference.ts', '--fixture', '--directory', referenceDir], { APP_MODE: 'demo', REFERENCE_DIR: referenceDir }); @@ -48,25 +49,56 @@ try { TYPESAFE_API_KEY: 'sentinel-not-used-when-assistant-is-disabled', }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', }); - await waitForServer(); + await waitForServer(base, releaseId); const headers = { origin: base, 'content-type': 'application/json' }; const chat = await fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) }); - assertStatus(chat, 503, 'assistant dispatch'); + await assertStatus(chat, 503, 'assistant dispatch'); if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing'); const sync = await fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } }); - assertStatus(sync, 503, 'asset sync'); + await assertStatus(sync, 503, 'asset sync'); const token = await fetch(`${base}/api/session/token`); - assertStatus(token, 503, 'session token'); + await assertStatus(token, 503, 'session token'); const characters = await fetch(`${base}/characters`); - assertStatus(characters, 200, 'character page'); + await assertStatus(characters, 200, 'character page'); if (!(await characters.text()).includes('identity')) throw new Error('identity gate reason missing'); const oauth = await fetch(`${base}/auth/eve`, { method: 'POST', headers: { origin: base, 'content-type': 'application/x-www-form-urlencoded' }, redirect: 'manual' }); - assertStatus(oauth, 503, 'OAuth initiation'); + await assertStatus(oauth, 503, 'OAuth initiation'); const callback = await fetch(`${base}/auth/eve/callback?state=synthetic`, { redirect: 'manual' }); - assertStatus(callback, 303, 'OAuth callback'); + await assertStatus(callback, 303, 'OAuth callback'); if (!callback.headers.get('location')?.includes('auth=disabled')) throw new Error('OAuth callback gate reason missing'); - console.log(`Rollout-gate HTTP smoke passed: disabled capabilities returned fail-closed responses; ${base}`); + server.kill('SIGTERM'); + await server.exited; + server = undefined; + + // Live-mode trap: a synthetic session cookie makes any accidental session() + // call observable without contacting Convex or a provider. + let trapCalls = 0; + trap = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => { trapCalls++; return new Response('null', { headers: { 'content-type': 'application/json' } }); } }); + const livePort = await freePort(); + const liveBase = `http://127.0.0.1:${livePort}`; + const liveRelease = `gate-live-${crypto.randomUUID()}`; + server = Bun.spawn(['bun', 'build/index.js'], { + env: { + ...safeEnv, APP_MODE: 'live', APP_ORIGIN: liveBase, ORIGIN: liveBase, HOST: '127.0.0.1', PORT: String(livePort), + REFERENCE_DIR: referenceDir, APP_RELEASE_ID: liveRelease, APP_CAPABILITY_ALLOWLIST: '', + CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`, + CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used', + }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', + }); + await waitForServer(liveBase, liveRelease); + const cookie = { cookie: 'eve_session=synthetic-session' }; + const liveChat = await fetch(`${liveBase}/api/chat`, { method: 'POST', headers: { ...headers, origin: liveBase, ...cookie }, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) }); + await assertStatus(liveChat, 503, 'authenticated chat gate'); + const liveSync = await fetch(`${liveBase}/api/sync`, { method: 'POST', headers: { origin: liveBase, ...cookie } }); + await assertStatus(liveSync, 503, 'authenticated sync gate'); + const liveCharacters = await fetch(`${liveBase}/characters`, { headers: cookie }); + await assertStatus(liveCharacters, 200, 'authenticated character gate'); + const liveAssets = await fetch(`${liveBase}/assets`, { headers: cookie }); + await assertStatus(liveAssets, 200, 'authenticated asset gate'); + if (trapCalls !== 0) throw new Error(`disabled route performed ${trapCalls} Convex session calls`); + console.log(`Rollout-gate HTTP smoke passed: anonymous and authenticated disabled paths avoided provider/session calls`); } finally { if (server) { server.kill('SIGTERM'); await server.exited; } + trap?.stop(); await rm(referenceDir, { recursive: true, force: true }); } diff --git a/scripts/smoke-restore-marker.ts b/scripts/smoke-restore-marker.ts new file mode 100644 index 0000000..4092f0e --- /dev/null +++ b/scripts/smoke-restore-marker.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { ConvexStore } from '../src/lib/server/store'; +import { hashSecret } from '../src/lib/server/security'; + +if (process.env.SMOKE_ALLOW_WRITE !== '1') throw new Error('Set SMOKE_ALLOW_WRITE=1 only for a disposable backend'); +const marker = process.env.RESTORE_MARKER; +const phase = process.env.RESTORE_MARKER_PHASE ?? 'write'; +const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? '998000001'); +if (!marker || !/^[A-Za-z0-9._-]{8,100}$/.test(marker)) throw new Error('RESTORE_MARKER must be a synthetic 8–100 character identifier'); +if (!Number.isSafeInteger(characterId) || characterId < 90_000_000) throw new Error('RESTORE_MARKER_CHARACTER_ID must be a synthetic safe integer'); +const store = new ConvexStore(process.env.CONVEX_SELF_HOSTED_URL!, process.env.CONVEX_BRIDGE_SECRET!); +const sessionHash = hashSecret(`restore-marker-session:${marker}`); +const ownerHash = hashSecret(`restore-marker-owner:${marker}`); + +if (phase === 'write') { + const now = Date.now(); + const user = await store.completeLogin({ + characterId, + name: `Restore marker ${marker}`, + ownerHash, + scopes: ['esi-assets.read_assets.v1'], + encryptedTokens: `restore-marker-placeholder:${marker}`, + tokenExpiresAt: now + 60_000, + sessionHash, + sessionExpiresAt: now + 60 * 60_000, + }); + console.log(`Restore marker written: character=${characterId}, user=${user.userId}`); +} else if (phase === 'verify') { + const session = await store.session(sessionHash); + assert(session, 'restore marker session is missing'); + const characters = await store.characters(session.userId); + assert(characters.some((character) => character.id === characterId && character.name === `Restore marker ${marker}`), 'restore marker character is missing or changed'); + console.log(`Restore marker verified: character=${characterId}, user=${session.userId}`); +} else if (phase === 'expect-invalidated') { + assert.equal(await store.session(sessionHash), null, 'restore marker session remains usable; invalidation is incomplete'); + console.log(`Restore marker invalidation verified: character=${characterId}`); +} else { + throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated'); +} diff --git a/src/convex/functions.ts b/src/convex/functions.ts index bc8e3fd..03acf70 100644 --- a/src/convex/functions.ts +++ b/src/convex/functions.ts @@ -5,7 +5,7 @@ import { } from "convex/server"; import { v } from "convex/values"; import { assetValidator } from "./schema"; -import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate"; +import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate"; const MAX_ASSET_CHUNK = 250; const SYNC_LEASE_MS = 5 * 60 * 1000; @@ -43,7 +43,7 @@ function assertBridge(secret: string): void { function assertDeploymentCapability(capability: 'asset_sync'): void { let enabled = false; try { - enabled = capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); + enabled = capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); } catch { // Invalid backend rollout configuration fails closed. } diff --git a/src/convex/status.ts b/src/convex/status.ts index 5413f6a..e504e26 100644 --- a/src/convex/status.ts +++ b/src/convex/status.ts @@ -1,7 +1,7 @@ import { queryGeneric } from "convex/server"; import { v } from "convex/values"; import { characterStateValidator } from "./schema"; -import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate"; +import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate"; const safeAssetSyncValidator = v.object({ status: characterStateValidator, @@ -27,7 +27,7 @@ function isUsableSession(session: any, timestamp = Date.now()): boolean { function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean { try { - return capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); + return capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); } catch { // A malformed backend setting must fail closed, not expose a reactive // tenant projection while the web process reports a configuration error. diff --git a/src/lib/server/release-gate.ts b/src/lib/server/release-gate.ts index 9d5fc93..91924a6 100644 --- a/src/lib/server/release-gate.ts +++ b/src/lib/server/release-gate.ts @@ -68,6 +68,12 @@ export function parseCapabilityAllowlist(raw: string | undefined): readonly Rele return result; } +/** Backend/Convex policy must be explicitly propagated; omission fails closed. */ +export function parseBackendCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] { + if (raw === undefined) throw new CapabilityConfigurationError('APP_CAPABILITY_ALLOWLIST is required in the backend deployment'); + return parseCapabilityAllowlist(raw); +} + export function capabilityEnabled( capability: ReleaseCapability, allowlist: readonly ReleaseCapability[], diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index e60361c..7149c40 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,7 +1,16 @@ import type { LayoutServerLoad } from './$types'; import { configuration } from '$lib/server/config'; import { session } from '$lib/server/application'; +import { capabilityEnabled } from '$lib/server/release-gate'; + export const load: LayoutServerLoad = async (event) => { - const config = configuration(); const current = await session(event); + const config = configuration(); + const path = event.url.pathname; + const identityPage = path === '/characters' || path.startsWith('/characters/'); + const assetPage = path === '/assets' || path.startsWith('/assets/'); + const sessionNeeded = !identityPage && !assetPage + || identityPage && capabilityEnabled('identity', config.capabilityAllowlist) + || assetPage && (capabilityEnabled('asset_search', config.capabilityAllowlist) || capabilityEnabled('asset_valuation', config.capabilityAllowlist)); + const current = sessionNeeded ? await session(event) : null; return { mode: config.mode, userId: current?.userId ?? null, convexUrl: config.publicConvexUrl, jevEnabled: !!config.jevKey }; }; diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index fc1a379..765e005 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -9,17 +9,17 @@ import { answerAssets } from '$lib/server/assistant/service'; import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate'; const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() }); export const POST: RequestHandler = async (event) => { - const current = await session(event); - protectMutation(event, `chat:${current?.userId ?? event.getClientAddress()}`); - const parsed = schema.safeParse(await boundedJson(event)); - if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 }); - const { message, requestId } = parsed.data; const config = configuration(); - // Check the rollout gate before invoking either the external classifier or - // the shared asset service. Hiding a button is not a server-side gate. + // Keep disabled routes from touching Convex/session/provider state. CSRF and + // rate protection still apply before the fail-closed response. + protectMutation(event, `chat:${event.getClientAddress()}`); if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) { return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); } + const current = await session(event); + const parsed = schema.safeParse(await boundedJson(event)); + if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 }); + const { message, requestId } = parsed.data; const manual = new ManualIntentRouter(); // Explicit commands remain reliable even when JEV is configured/unavailable. const router = message === '/assets' || message === '/value' || !config.jevKey ? manual : new JevIntentRouter({ apiKey: config.jevKey }); diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index fe7091b..b7927c2 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -5,11 +5,12 @@ import { protectMutation } from '$lib/server/http'; import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate'; import { configuration } from '$lib/server/config'; export const POST: RequestHandler = async (event) => { - const current = await session(event); - protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6); - if (!capabilityEnabled('asset_sync', configuration().capabilityAllowlist)) { + const config = configuration(); + protectMutation(event, `sync:${event.getClientAddress()}`, 6); + if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) { return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); } + const current = await session(event); if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 }); try { const result = await synchronize(current.userId); diff --git a/src/routes/assets/+page.server.ts b/src/routes/assets/+page.server.ts index 53683e8..e01e292 100644 --- a/src/routes/assets/+page.server.ts +++ b/src/routes/assets/+page.server.ts @@ -1,3 +1,13 @@ import type { PageServerLoad } from './$types'; import { assetReport, session } from '$lib/server/application'; -export const load: PageServerLoad = async (event) => ({ result: await assetReport((await session(event))?.userId ?? null, 'asset_search') }); +import { configuration } from '$lib/server/config'; +import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate'; +export const load: PageServerLoad = async (event) => { + const config = configuration(); + for (const capability of ['asset_search', 'asset_valuation'] as const) { + if (!capabilityEnabled(capability, config.capabilityAllowlist)) { + return { result: { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) } }; + } + } + return { result: await assetReport((await session(event))?.userId ?? null, 'asset_search') }; +}; diff --git a/src/routes/characters/+page.server.ts b/src/routes/characters/+page.server.ts index 09acfc6..c722838 100644 --- a/src/routes/characters/+page.server.ts +++ b/src/routes/characters/+page.server.ts @@ -2,17 +2,23 @@ import type { PageServerLoad } from './$types'; import { charactersFor, session } from '$lib/server/application'; import { configuration } from '$lib/server/config'; import { capabilityEnabled, capabilityDisabledReason } from '$lib/server/release-gate'; + export const load: PageServerLoad = async (event) => { const config = configuration(); const identityEnabled = capabilityEnabled('identity', config.capabilityAllowlist); + if (!identityEnabled) { + return { + characters: [], + identityEnabled: false, + error: capabilityDisabledReason('identity'), + }; + } const current = await session(event); return { - characters: identityEnabled ? await charactersFor(current?.userId ?? null) : [], - identityEnabled, - error: !identityEnabled - ? capabilityDisabledReason('identity') - : event.url.searchParams.get('auth') === 'failed' - ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' - : null, + characters: await charactersFor(current?.userId ?? null), + identityEnabled: true, + error: event.url.searchParams.get('auth') === 'failed' + ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' + : null, }; }; diff --git a/tests/convex.test.ts b/tests/convex.test.ts index 22fac18..2c3ab67 100644 --- a/tests/convex.test.ts +++ b/tests/convex.test.ts @@ -471,4 +471,23 @@ describe("Convex persistence", () => { else process.env.APP_CAPABILITY_ALLOWLIST = previous; } }); + + test("backend rollout gate fails closed when propagation is missing or malformed", async () => { + const previous = process.env.APP_CAPABILITY_ALLOWLIST; + try { + for (const value of [undefined, "not-a-capability"]) { + if (value === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; + else process.env.APP_CAPABILITY_ALLOWLIST = value; + process.env.APP_AUTH_ISSUER = "https://auth.example.test"; + const t = convexTest({ schema, modules }); + const loggedIn = await login(t, { sessionHash: `missing-gate-${value ?? 'unset'}` }); + const browser = t.withIdentity({ subject: loggedIn.userId, issuer: "https://auth.example.test", sid: loggedIn.sessionId } as any); + await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); + await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100 })).rejects.toThrow("asset_sync"); + } + } finally { + if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; + else process.env.APP_CAPABILITY_ALLOWLIST = previous; + } + }); }); diff --git a/tests/release-safety.test.ts b/tests/release-safety.test.ts index a0c696e..5979e04 100644 --- a/tests/release-safety.test.ts +++ b/tests/release-safety.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { BASELINE_RELEASE_CAPABILITIES, capabilityEnabled, + parseBackendCapabilityAllowlist, parseCapabilityAllowlist, releaseIdIsImmutable, } from '../src/lib/server/release-gate'; @@ -23,6 +24,8 @@ describe('deployment rollout gate', () => { expect(parseCapabilityAllowlist('')).toEqual([]); expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability'); expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability'); + expect(parseBackendCapabilityAllowlist('asset_sync')).toEqual(['asset_sync']); + expect(() => parseBackendCapabilityAllowlist(undefined)).toThrow('required in the backend deployment'); }); test('requires a non-placeholder production release identifier', () => { diff --git a/tests/workflow-config.test.ts b/tests/workflow-config.test.ts index 3fab530..c275f33 100644 --- a/tests/workflow-config.test.ts +++ b/tests/workflow-config.test.ts @@ -5,14 +5,17 @@ const workflow = readFileSync('.gitea/workflows/ci.yml', 'utf8'); const compose = readFileSync('compose.yaml', 'utf8'); const dockerfile = readFileSync('Dockerfile', 'utf8'); const dockerignore = readFileSync('.dockerignore', 'utf8'); +const convexConfig = readFileSync('scripts/configure-convex.ts', 'utf8'); -test('Gitea workflow separates PR source checks from trusted container builds', () => { +test('Gitea workflow keeps source checks separate and holds unverified container CI', () => { expect(workflow).toContain('pull_request:'); - expect(workflow).toContain("if: ${{ gitea.event_name == 'push' }}"); + expect(workflow).toContain('if: ${{ false }}'); + expect(workflow).toContain('GITHUB_RUN_ID is required'); expect(workflow).toContain('RELEASE_ENV=staging'); expect(workflow).toContain('bun run test:release-gates'); expect(workflow).toContain('docker compose config --quiet'); expect(workflow).toContain('docker build --build-arg APP_RELEASE_ID=fixture-ci'); + expect(workflow).toContain('GITHUB_RUN_ATTEMPT'); expect(workflow).toContain('--network none'); }); @@ -27,4 +30,5 @@ test('Compose and Docker defaults are isolated and digest-pinned', () => { for (const pattern of ['*.pem', '*.key', '*.p12', '*.pfx', 'credentials*', 'secrets*', '.npmrc']) { expect(dockerignore).toContain(pattern); } + expect(convexConfig).toContain("'APP_CAPABILITY_ALLOWLIST'"); });