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}" image="eve-intelligence:fixture-${run_id}-${run_attempt}"
volume="eden-ci-reference-${run_id}-${run_attempt}" volume="eden-ci-reference-${run_id}-${run_attempt}"
container="eden-ci-web-${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 trap cleanup EXIT
docker volume create "$volume" >/dev/null docker volume create "$volume" >/dev/null
docker run --rm --network none --user root -v "$volume:/app/data/reference" "$image" \ 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/sync` and the application synchronizer check `asset_sync`.
- `/api/session/token` checks `session_token`. - `/api/session/token` checks `session_token`.
- Character management and OAuth entry points check `identity`. - 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 - The Convex browser status subscription checks `subscriptions`; sync bridge
mutations check `asset_sync` in the backend authorization plane too. The mutations check `asset_sync` in the backend authorization plane too. The
allowlist is copied into Convex by `bun run convex:configure`; that setting 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" restore_project="${project}-restored"
work="$(mktemp -d "${TMPDIR:-/tmp}/eden-restore.XXXXXX")" work="$(mktemp -d "${TMPDIR:-/tmp}/eden-restore.XXXXXX")"
chmod 700 "$work" 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 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 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() { cleanup() {
docker compose -p "$project" down -v >/dev/null 2>&1 || true compose -p "$project" down -v >/dev/null 2>&1 || true
docker compose -p "$restore_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 # Export/copy the evidence bundle before this cleanup; the temporary key and
# archives are intentionally removed only after retention is recorded. # archives are intentionally removed only after retention is recorded.
rm -rf "$work" rm -rf "$work"
@@ -58,13 +87,18 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
(cd "$work" && bun "$repo/scripts/generate-keys.ts") (cd "$work" && bun "$repo/scripts/generate-keys.ts")
chmod 600 "$work/.env.keys" chmod 600 "$work/.env.keys"
set -a
. "$work/.env.keys"
set +a
``` ```
Load only those synthetic values into a temporary shell environment. Set The bootstrap above loads only synthetic values into a temporary shell
`APP_MODE=demo`, `APP_RELEASE_ID=restore-fixture`, environment and writes the explicit Compose env file. It allocates loopback
`APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions`, backend, site, web, issuer, public URL, and callback origins together; when
and an issuer such as `http://127.0.0.1:18080`. Do not use a production restore ports change, update all corresponding exported values before starting
callback or provider key. 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 ## Backup rehearsal
@@ -73,7 +107,7 @@ callback or provider key.
```sh ```sh
export COMPOSE_PROJECT_NAME="$project" export COMPOSE_PROJECT_NAME="$project"
docker compose -p "$project" up -d backend compose -p "$project" up -d backend
volume="${project}_convex-data" volume="${project}_convex-data"
mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$volume")" mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$volume")"
source_bytes_before="$(du -sb "$mount" | awk '{print $1}')" 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: key fingerprint or assume an image-specific helper command:
```sh ```sh
read -r -s CONVEX_SELF_HOSTED_ADMIN_KEY read -r -s admin_key
export CONVEX_SELF_HOSTED_ADMIN_KEY CONVEX_SELF_HOSTED_ADMIN_KEY="$admin_key" \
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/configure-convex.ts" >/dev/null bun "$repo/scripts/configure-convex.ts" >/dev/null
``` ```
@@ -111,19 +145,25 @@ callback or provider key.
```sh ```sh
export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic" export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic"
export RESTORE_MARKER_CHARACTER_ID=998000001 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" \ 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" 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 4. Create a consistent filesystem backup while the disposable backend is
stopped. The Convex data volume contains the backend data and instance stopped. The Convex data volume contains the backend data and instance
credential material; back up it as one unit. A host administrator may need credential material; back up it as one unit. A host administrator may need
root access to the Docker volume mount. root access to the Docker volume mount.
```sh ```sh
docker compose -p "$project" stop backend compose -p "$project" stop backend
tar --xattrs --acls --numeric-owner -C "$mount" \ tar --xattrs --acls --numeric-owner -C "$mount" \
-czf "$work/convex-data.tar.gz" . -czf "$work/convex-data.tar.gz" .
sha256sum "$work/convex-data.tar.gz" > "$work/convex-data.tar.gz.sha256" 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" \ tar --xattrs --acls --numeric-owner -C "$restore_mount" \
-xzf "$work/convex-data.tar.gz" -xzf "$work/convex-data.tar.gz"
export CONVEX_PORT=$((5200 + RANDOM % 500)) CONVEX_SITE_PORT=$((5700 + RANDOM % 500)) WEB_PORT=$((6200 + RANDOM % 500)) 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 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: instance-credential continuity is unproven and traffic must remain closed:
```sh ```sh
docker compose -p "$restore_project" up -d backend compose -p "$restore_project" up -d backend
CONVEX_SELF_HOSTED_URL=http://127.0.0.1:"$CONVEX_PORT" \ 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 bun "$repo/scripts/configure-convex.ts" >/dev/null
``` ```
@@ -169,9 +214,9 @@ callback or provider key.
the synthetic Convex smoke with fresh IDs: the synthetic Convex smoke with fresh IDs:
```sh ```sh
SMOKE_ALLOW_WRITE=1 RESTORE_MARKER="$RESTORE_MARKER" \ RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ 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" RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts"
``` ```
+2
View File
@@ -87,6 +87,8 @@ try {
}); });
await waitForServer(liveBase, liveRelease); await waitForServer(liveBase, liveRelease);
const cookie = { cookie: 'eve_session=synthetic-session' }; 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() }) }); 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'); await assertStatus(liveChat, 503, 'authenticated chat gate');
const liveSync = await fetch(`${liveBase}/api/sync`, { method: 'POST', headers: { origin: liveBase, ...cookie } }); 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 assert from 'node:assert/strict';
import { ConvexStore } from '../src/lib/server/store'; import { ConvexStore } from '../src/lib/server/store';
import { hashSecret } from '../src/lib/server/security'; 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 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'); 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 (!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'); 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 sessionHash = hashSecret(`restore-marker-session:${marker}`);
const ownerHash = hashSecret(`restore-marker-owner:${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 type { LayoutServerLoad } from './$types';
import { configuration } from '$lib/server/config'; import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application'; 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) => { export const load: LayoutServerLoad = async (event) => {
const config = configuration(); const config = configuration();
const path = event.url.pathname; const path = event.url.pathname;
const identityPage = path === '/characters' || path.startsWith('/characters/'); const identityPage = path === '/characters' || path.startsWith('/characters/');
const assetPage = path === '/assets' || path.startsWith('/assets/'); const assetPage = path === '/assets' || path.startsWith('/assets/');
const sessionNeeded = !identityPage && !assetPage const sessionNeeded = identityPage
|| identityPage && capabilityEnabled('identity', config.capabilityAllowlist) ? capabilityEnabled('identity', config.capabilityAllowlist)
|| assetPage && (capabilityEnabled('asset_search', config.capabilityAllowlist) || capabilityEnabled('asset_valuation', 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; const current = sessionNeeded ? await session(event) : null;
return { mode: config.mode, userId: current?.userId ?? null, convexUrl: config.publicConvexUrl, jevEnabled: !!config.jevKey }; 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() }); const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const config = configuration(); const config = configuration();
// Keep disabled routes from touching Convex/session/provider state. CSRF and // Keep disabled routes from touching Convex/session/provider state. The
// rate protection still apply before the fail-closed response. // normal user-keyed limiter is restored after session resolution below.
protectMutation(event, `chat:${event.getClientAddress()}`);
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) { if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
} }
const current = await session(event); const current = await session(event);
protectMutation(event, `chat:${current?.userId ?? event.getClientAddress()}`);
const parsed = schema.safeParse(await boundedJson(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 }); if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
const { message, requestId } = parsed.data; 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'; import { configuration } from '$lib/server/config';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const config = configuration(); const config = configuration();
protectMutation(event, `sync:${event.getClientAddress()}`, 6);
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) { if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
} }
const current = await session(event); 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 }); if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try { try {
const result = await synchronize(current.userId); 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 dockerfile = readFileSync('Dockerfile', 'utf8');
const dockerignore = readFileSync('.dockerignore', 'utf8'); const dockerignore = readFileSync('.dockerignore', 'utf8');
const convexConfig = readFileSync('scripts/configure-convex.ts', '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', () => { test('Gitea workflow keeps source checks separate and holds unverified container CI', () => {
expect(workflow).toContain('pull_request:'); 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 compose config --quiet');
expect(workflow).toContain('docker build --build-arg APP_RELEASE_ID=fixture-ci'); expect(workflow).toContain('docker build --build-arg APP_RELEASE_ID=fixture-ci');
expect(workflow).toContain('GITHUB_RUN_ATTEMPT'); expect(workflow).toContain('GITHUB_RUN_ATTEMPT');
expect(workflow).toContain('docker image rm "$image"');
expect(workflow).toContain('--network none'); 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', () => { test('Compose and Docker defaults are isolated and digest-pinned', () => {
expect(compose).toContain('CONVEX_BIND_ADDRESS:-127.0.0.1'); expect(compose).toContain('CONVEX_BIND_ADDRESS:-127.0.0.1');
expect(compose).toContain('WEB_BIND_ADDRESS:-127.0.0.1'); expect(compose).toContain('WEB_BIND_ADDRESS:-127.0.0.1');