diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c11516d..eadfdfd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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" \ diff --git a/docs/release-safety.md b/docs/release-safety.md index 6f720ef..dd3ed82 100644 --- a/docs/release-safety.md +++ b/docs/release-safety.md @@ -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 diff --git a/docs/restore-rehearsal.md b/docs/restore-rehearsal.md index 7eebd40..86a304d 100644 --- a/docs/restore-rehearsal.md +++ b/docs/restore-rehearsal.md @@ -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" </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" ``` diff --git a/scripts/release-gate-smoke.ts b/scripts/release-gate-smoke.ts index d424ef9..f02de67 100644 --- a/scripts/release-gate-smoke.ts +++ b/scripts/release-gate-smoke.ts @@ -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 } }); diff --git a/scripts/smoke-restore-marker.ts b/scripts/smoke-restore-marker.ts index 4092f0e..a2dfd8e 100644 --- a/scripts/smoke-restore-marker.ts +++ b/scripts/smoke-restore-marker.ts @@ -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}`); diff --git a/src/lib/server/restore-marker.ts b/src/lib/server/restore-marker.ts new file mode 100644 index 0000000..6ff151f --- /dev/null +++ b/src/lib/server/restore-marker.ts @@ -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}`); + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 7149c40..fa432f2 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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 }; }; diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index 765e005..d45fff5 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -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; diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index b7927c2..43215bd 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -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); diff --git a/tests/restore-marker.test.ts b/tests/restore-marker.test.ts new file mode 100644 index 0000000..bd5bac0 --- /dev/null +++ b/tests/restore-marker.test.ts @@ -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(); +}); diff --git a/tests/workflow-config.test.ts b/tests/workflow-config.test.ts index c275f33..5f5c4c4 100644 --- a/tests/workflow-config.test.ts +++ b/tests/workflow-config.test.ts @@ -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');