fix: fail closed backend capabilities and isolate gate smoke
This commit is contained in:
+14
-9
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') };
|
||||
};
|
||||
|
||||
@@ -2,16 +2,22 @@ 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'
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user