fix: preserve route limits and harden restore marker
This commit is contained in:
@@ -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" \
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user