fix: preserve route limits and harden restore marker

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 99fe7e1ca2
commit cb86497724
11 changed files with 171 additions and 30 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
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; }
cleanup() { docker rm --force "$container" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null 2>&1 || true; docker image rm "$image" >/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" "$image" \
+4
View File
@@ -29,6 +29,10 @@ The web server checks the allowlist at the relevant boundary:
- `/api/sync` and the application synchronizer check `asset_sync`.
- `/api/session/token` checks `session_token`.
- Character management and OAuth entry points check `identity`.
- The root layout skips its authenticated session lookup only when every
capability represented by the root assistant/navigation shell is disabled;
if a separate asset, identity, session-token, subscription, or assistant
capability remains enabled, it preserves the existing user session state.
- The Convex browser status subscription checks `subscriptions`; sync bridge
mutations check `asset_sync` in the backend authorization plane too. The
allowlist is copied into Convex by `bun run convex:configure`; that setting
+63 -18
View File
@@ -46,11 +46,40 @@ project="eden-restore-${USER:-operator}-$(date +%s)-$$"
restore_project="${project}-restored"
work="$(mktemp -d "${TMPDIR:-/tmp}/eden-restore.XXXXXX")"
chmod 700 "$work"
# Do not let an ambient shell, repository .env, or prior Compose project alter
# this rehearsal. The explicit env file below contains only non-secret values;
# synthetic key values are loaded separately from the mode-0600 key file.
unset COMPOSE_FILE COMPOSE_ENV_FILES COMPOSE_PROJECT_NAME COMPOSE_PROFILES DOCKER_HOST APP_MODE APP_ORIGIN ORIGIN APP_RELEASE_ID APP_CAPABILITY_ALLOWLIST CONVEX_URL PUBLIC_CONVEX_URL CONVEX_SITE_ORIGIN CONVEX_PORT CONVEX_SITE_PORT WEB_PORT CONVEX_IMAGE CONVEX_DASHBOARD_IMAGE EVE_CALLBACK_URL EVE_CLIENT_ID EVE_CLIENT_SECRET ESI_USER_AGENT TYPESAFE_API_KEY APP_AUTH_ISSUER TOKEN_ENCRYPTION_KEY CONVEX_BRIDGE_SECRET APP_AUTH_PRIVATE_KEY APP_AUTH_JWKS CONVEX_SELF_HOSTED_URL CONVEX_SELF_HOSTED_ADMIN_KEY
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))
export APP_MODE=live APP_RELEASE_ID=restore-fixture APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions
export APP_ORIGIN="http://127.0.0.1:$WEB_PORT" ORIGIN="$APP_ORIGIN" APP_AUTH_ISSUER="$APP_ORIGIN"
export PUBLIC_CONVEX_URL="http://127.0.0.1:$CONVEX_PORT" CONVEX_SITE_ORIGIN="http://127.0.0.1:$CONVEX_SITE_PORT"
export EVE_CALLBACK_URL="$APP_ORIGIN/auth/eve/callback"
write_compose_env() {
cat > "$work/compose.env" <<EOF
COMPOSE_PROJECT_NAME=$project
CONVEX_BIND_ADDRESS=127.0.0.1
CONVEX_PORT=$CONVEX_PORT
CONVEX_SITE_PORT=$CONVEX_SITE_PORT
WEB_BIND_ADDRESS=127.0.0.1
WEB_PORT=$WEB_PORT
APP_MODE=$APP_MODE
APP_RELEASE_ID=$APP_RELEASE_ID
APP_CAPABILITY_ALLOWLIST=$APP_CAPABILITY_ALLOWLIST
APP_ORIGIN=$APP_ORIGIN
ORIGIN=$ORIGIN
APP_AUTH_ISSUER=$APP_AUTH_ISSUER
PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
CONVEX_SITE_ORIGIN=$CONVEX_SITE_ORIGIN
EVE_CALLBACK_URL=$EVE_CALLBACK_URL
EOF
}
write_compose_env
compose() { docker compose --project-directory "$repo" --file "$repo/compose.yaml" --env-file "$work/compose.env" "$@"; }
cleanup() {
docker compose -p "$project" down -v >/dev/null 2>&1 || true
docker compose -p "$restore_project" down -v >/dev/null 2>&1 || true
compose -p "$project" down -v >/dev/null 2>&1 || true
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"
@@ -58,13 +87,18 @@ cleanup() {
trap cleanup EXIT
(cd "$work" && bun "$repo/scripts/generate-keys.ts")
chmod 600 "$work/.env.keys"
set -a
. "$work/.env.keys"
set +a
```
Load only those synthetic values into a temporary shell environment. Set
`APP_MODE=demo`, `APP_RELEASE_ID=restore-fixture`,
`APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions`,
and an issuer such as `http://127.0.0.1:18080`. Do not use a production
callback or provider key.
The bootstrap above loads only synthetic values into a temporary shell
environment and writes the explicit Compose env file. It allocates loopback
backend, site, web, issuer, public URL, and callback origins together; when
restore ports change, update all corresponding exported values before starting
the restored project. The Compose web service intentionally uses the
service-internal `http://backend:3210`; only host/public settings use the
allocated loopback ports. Do not use a production callback or provider key.
## Backup rehearsal
@@ -73,7 +107,7 @@ callback or provider key.
```sh
export COMPOSE_PROJECT_NAME="$project"
docker compose -p "$project" up -d backend
compose -p "$project" up -d backend
volume="${project}_convex-data"
mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$volume")"
source_bytes_before="$(du -sb "$mount" | awk '{print $1}')"
@@ -87,9 +121,9 @@ callback or provider key.
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" \
read -r -s admin_key
CONVEX_SELF_HOSTED_ADMIN_KEY="$admin_key" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/configure-convex.ts" >/dev/null
```
@@ -111,19 +145,25 @@ callback or provider key.
```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" \
SMOKE_ALLOW_WRITE=1 RESTORE_MARKER_CONFIRMATION=DISPOSABLE_LOOPBACK_ONLY \
RESTORE_MARKER_PHASE=write RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log"
```
The marker tool defaults to non-writing verification, rejects non-loopback
targets before constructing a backend client, and requires both the explicit
write phase and `DISPOSABLE_LOOPBACK_ONLY` confirmation. Never reuse the
marker command against a shared, staging, or production URL.
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
root access to the Docker volume mount.
```sh
docker compose -p "$project" stop backend
compose -p "$project" stop backend
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"
@@ -151,6 +191,10 @@ callback or provider key.
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))
export APP_ORIGIN="http://127.0.0.1:$WEB_PORT" ORIGIN="$APP_ORIGIN" APP_AUTH_ISSUER="$APP_ORIGIN"
export PUBLIC_CONVEX_URL="http://127.0.0.1:$CONVEX_PORT" CONVEX_SITE_ORIGIN="http://127.0.0.1:$CONVEX_SITE_PORT"
export EVE_CALLBACK_URL="$APP_ORIGIN/auth/eve/callback"
write_compose_env
```
2. Start the restored backend under the second project and configure the web
@@ -160,8 +204,9 @@ callback or provider key.
instance-credential continuity is unproven and traffic must remain closed:
```sh
docker compose -p "$restore_project" up -d backend
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \
compose -p "$restore_project" up -d backend
CONVEX_SELF_HOSTED_ADMIN_KEY="$admin_key" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/configure-convex.ts" >/dev/null
```
@@ -169,9 +214,9 @@ callback or provider key.
the synthetic Convex smoke with fresh IDs:
```sh
SMOKE_ALLOW_WRITE=1 RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts"
```
+2
View File
@@ -87,6 +87,8 @@ try {
});
await waitForServer(liveBase, liveRelease);
const cookie = { cookie: 'eve_session=synthetic-session' };
const liveRoot = await fetch(liveBase, { headers: cookie });
await assertStatus(liveRoot, 200, 'authenticated fully-disabled root');
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 } });
+13 -3
View File
@@ -1,14 +1,24 @@
import assert from 'node:assert/strict';
import { ConvexStore } from '../src/lib/server/store';
import { hashSecret } from '../src/lib/server/security';
import {
parseRestoreMarkerPhase,
validateRestoreMarkerTarget,
validateRestoreMarkerWrite,
} from '../src/lib/server/restore-marker';
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 phase = parseRestoreMarkerPhase(process.env.RESTORE_MARKER_PHASE);
const target = validateRestoreMarkerTarget(process.env.CONVEX_SELF_HOSTED_URL);
validateRestoreMarkerWrite({
phase,
allowWrite: process.env.SMOKE_ALLOW_WRITE,
confirmation: process.env.RESTORE_MARKER_CONFIRMATION,
});
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 store = new ConvexStore(target.toString(), process.env.CONVEX_BRIDGE_SECRET!);
const sessionHash = hashSecret(`restore-marker-session:${marker}`);
const ownerHash = hashSecret(`restore-marker-owner:${marker}`);
+35
View File
@@ -0,0 +1,35 @@
export const RESTORE_MARKER_CONFIRMATION = 'DISPOSABLE_LOOPBACK_ONLY';
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
export type RestoreMarkerPhase = 'verify' | 'write' | 'expect-invalidated';
export function parseRestoreMarkerPhase(raw: string | undefined): RestoreMarkerPhase {
if (raw === undefined || raw === '') return 'verify';
if (raw === 'write' || raw === 'verify' || raw === 'expect-invalidated') return raw;
throw new Error('RESTORE_MARKER_PHASE must be verify, write, or expect-invalidated');
}
export function validateRestoreMarkerTarget(raw: string | undefined): URL {
if (!raw) throw new Error('CONVEX_SELF_HOSTED_URL is required');
let target: URL;
try {
target = new URL(raw);
} catch {
throw new Error('CONVEX_SELF_HOSTED_URL must be a valid disposable loopback URL');
}
if (target.protocol !== 'http:' || !LOOPBACK_HOSTS.has(target.hostname)) {
throw new Error('CONVEX_SELF_HOSTED_URL must use an http loopback target (127.0.0.1, localhost, or ::1)');
}
return target;
}
export function validateRestoreMarkerWrite(input: {
phase: RestoreMarkerPhase;
allowWrite: string | undefined;
confirmation: string | undefined;
}): void {
if (input.phase !== 'write') return;
if (input.allowWrite !== '1' || input.confirmation !== RESTORE_MARKER_CONFIRMATION) {
throw new Error(`Writing requires SMOKE_ALLOW_WRITE=1 and RESTORE_MARKER_CONFIRMATION=${RESTORE_MARKER_CONFIRMATION}`);
}
}
+10 -4
View File
@@ -1,16 +1,22 @@
import type { LayoutServerLoad } from './$types';
import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application';
import { capabilityEnabled } from '$lib/server/release-gate';
import { capabilityEnabled, type ReleaseCapability } from '$lib/server/release-gate';
export const load: LayoutServerLoad = async (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 sessionNeeded = identityPage
? capabilityEnabled('identity', config.capabilityAllowlist)
: assetPage
? capabilityEnabled('asset_search', config.capabilityAllowlist) || capabilityEnabled('asset_valuation', config.capabilityAllowlist)
: (['assistant_dispatch', 'asset_search', 'asset_valuation', 'identity', 'session_token', 'subscriptions'] as readonly ReleaseCapability[])
.some((capability) => capabilityEnabled(capability, config.capabilityAllowlist));
// The root shell owns the assistant/nav session indicator. Skip its backend
// lookup only when every capability represented by that shell is disabled;
// separately enabled capabilities retain the existing authenticated state.
const current = sessionNeeded ? await session(event) : null;
return { mode: config.mode, userId: current?.userId ?? null, convexUrl: config.publicConvexUrl, jevEnabled: !!config.jevKey };
};
+3 -3
View File
@@ -10,13 +10,13 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release
const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => {
const config = configuration();
// 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()}`);
// Keep disabled routes from touching Convex/session/provider state. The
// normal user-keyed limiter is restored after session resolution below.
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
}
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;
+1 -1
View File
@@ -6,11 +6,11 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release
import { configuration } from '$lib/server/config';
export const POST: RequestHandler = async (event) => {
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);
protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6);
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try {
const result = await synchronize(current.userId);
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from 'bun:test';
import {
parseRestoreMarkerPhase,
RESTORE_MARKER_CONFIRMATION,
validateRestoreMarkerTarget,
validateRestoreMarkerWrite,
} from '../src/lib/server/restore-marker';
test('restore marker defaults to a non-writing verification phase', () => {
expect(parseRestoreMarkerPhase(undefined)).toBe('verify');
expect(parseRestoreMarkerPhase('')).toBe('verify');
});
test('restore marker rejects non-loopback targets before any backend call', () => {
expect(() => validateRestoreMarkerTarget(undefined)).toThrow('required');
expect(() => validateRestoreMarkerTarget('https://production.example.test')).toThrow('loopback');
expect(() => validateRestoreMarkerTarget('http://192.0.2.10:3210')).toThrow('loopback');
expect(validateRestoreMarkerTarget('http://127.0.0.1:3210').hostname).toBe('127.0.0.1');
});
test('restore marker requires explicit disposable confirmation for writes', () => {
expect(() => validateRestoreMarkerWrite({ phase: 'write', allowWrite: undefined, confirmation: undefined })).toThrow('SMOKE_ALLOW_WRITE');
expect(() => validateRestoreMarkerWrite({ phase: 'write', allowWrite: '1', confirmation: 'wrong' })).toThrow('RESTORE_MARKER_CONFIRMATION');
expect(() => validateRestoreMarkerWrite({ phase: 'write', allowWrite: '1', confirmation: RESTORE_MARKER_CONFIRMATION })).not.toThrow();
expect(() => validateRestoreMarkerWrite({ phase: 'verify', allowWrite: undefined, confirmation: undefined })).not.toThrow();
});
+13
View File
@@ -6,6 +6,9 @@ const compose = readFileSync('compose.yaml', 'utf8');
const dockerfile = readFileSync('Dockerfile', 'utf8');
const dockerignore = readFileSync('.dockerignore', 'utf8');
const convexConfig = readFileSync('scripts/configure-convex.ts', 'utf8');
const chatRoute = readFileSync('src/routes/api/chat/+server.ts', 'utf8');
const syncRoute = readFileSync('src/routes/api/sync/+server.ts', 'utf8');
const layoutRoute = readFileSync('src/routes/+layout.server.ts', 'utf8');
test('Gitea workflow keeps source checks separate and holds unverified container CI', () => {
expect(workflow).toContain('pull_request:');
@@ -16,9 +19,19 @@ test('Gitea workflow keeps source checks separate and holds unverified container
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('docker image rm "$image"');
expect(workflow).toContain('--network none');
});
test('Authenticated route limits remain user-keyed after rollout gates', () => {
expect(chatRoute).toContain('chat:${current?.userId ?? event.getClientAddress()}');
expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}');
expect(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));
expect(syncRoute.indexOf("capabilityEnabled('asset_sync'")).toBeLessThan(syncRoute.indexOf('const current = await session(event)'));
expect(layoutRoute).toContain('assistant_dispatch');
expect(layoutRoute).toContain('sessionNeeded');
});
test('Compose and Docker defaults are isolated and digest-pinned', () => {
expect(compose).toContain('CONVEX_BIND_ADDRESS:-127.0.0.1');
expect(compose).toContain('WEB_BIND_ADDRESS:-127.0.0.1');