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
+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);