fix: fail closed backend capabilities and isolate gate smoke

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 1371995cbe
commit 5b0076cb20
17 changed files with 344 additions and 81 deletions
+14 -9
View File
@@ -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
+29 -9
View File
@@ -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.
+132 -24
View File
@@ -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=<recorded-candidate-commit>
baseline_image=<recorded-digest-pinned-image>
candidate_image=<recorded-digest-pinned-image>
# approved migration command against the disposable candidate deployment:
<deploy-candidate-and-run-migration>
# restore pre-migration archive into a fresh volume/project:
<restore-pre-migration-volume>
# start baseline image against the restored volume:
<deploy-baseline-image-and-run-compatibility-checks>
```
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=<operator-set-expiry>\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:
+2 -1
View File
@@ -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",
+45 -13
View File
@@ -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<typeof Bun.serve> | undefined;
async function run(args: string[], env: Record<string, string>): Promise<void> {
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<void> {
async function waitForServer(baseUrl: string, expectedReleaseId: string): Promise<void> {
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<void> {
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 });
}
+39
View File
@@ -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');
}
+2 -2
View File
@@ -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.
}
+2 -2
View File
@@ -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.
+6
View File
@@ -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[],
+10 -1
View File
@@ -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 };
};
+7 -7
View File
@@ -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 });
+4 -3
View File
@@ -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);
+11 -1
View File
@@ -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') };
};
+13 -7
View File
@@ -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,
};
};
+19
View File
@@ -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;
}
});
});
+3
View File
@@ -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', () => {
+6 -2
View File
@@ -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'");
});