fix: fail closed backend capabilities and isolate gate smoke

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 1371995cbe
commit 5b0076cb20
17 changed files with 344 additions and 81 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ import {
} from "convex/server";
import { v } from "convex/values";
import { assetValidator } from "./schema";
import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
const MAX_ASSET_CHUNK = 250;
const SYNC_LEASE_MS = 5 * 60 * 1000;
@@ -43,7 +43,7 @@ function assertBridge(secret: string): void {
function assertDeploymentCapability(capability: 'asset_sync'): void {
let enabled = false;
try {
enabled = capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
enabled = capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
} catch {
// Invalid backend rollout configuration fails closed.
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { queryGeneric } from "convex/server";
import { v } from "convex/values";
import { characterStateValidator } from "./schema";
import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
const safeAssetSyncValidator = v.object({
status: characterStateValidator,
@@ -27,7 +27,7 @@ function isUsableSession(session: any, timestamp = Date.now()): boolean {
function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean {
try {
return capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
return capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
} catch {
// A malformed backend setting must fail closed, not expose a reactive
// tenant projection while the web process reports a configuration error.
+6
View File
@@ -68,6 +68,12 @@ export function parseCapabilityAllowlist(raw: string | undefined): readonly Rele
return result;
}
/** Backend/Convex policy must be explicitly propagated; omission fails closed. */
export function parseBackendCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] {
if (raw === undefined) throw new CapabilityConfigurationError('APP_CAPABILITY_ALLOWLIST is required in the backend deployment');
return parseCapabilityAllowlist(raw);
}
export function capabilityEnabled(
capability: ReleaseCapability,
allowlist: readonly ReleaseCapability[],
+10 -1
View File
@@ -1,7 +1,16 @@
import type { LayoutServerLoad } from './$types';
import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application';
import { capabilityEnabled } from '$lib/server/release-gate';
export const load: LayoutServerLoad = async (event) => {
const config = configuration(); const current = await session(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 current = sessionNeeded ? await session(event) : null;
return { mode: config.mode, userId: current?.userId ?? null, convexUrl: config.publicConvexUrl, jevEnabled: !!config.jevKey };
};
+7 -7
View File
@@ -9,17 +9,17 @@ import { answerAssets } from '$lib/server/assistant/service';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => {
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;
const config = configuration();
// Check the rollout gate before invoking either the external classifier or
// the shared asset service. Hiding a button is not a server-side gate.
// 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()}`);
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
}
const current = await session(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 });
const { message, requestId } = parsed.data;
const manual = new ManualIntentRouter();
// Explicit commands remain reliable even when JEV is configured/unavailable.
const router = message === '/assets' || message === '/value' || !config.jevKey ? manual : new JevIntentRouter({ apiKey: config.jevKey });
+4 -3
View File
@@ -5,11 +5,12 @@ import { protectMutation } from '$lib/server/http';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
import { configuration } from '$lib/server/config';
export const POST: RequestHandler = async (event) => {
const current = await session(event);
protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6);
if (!capabilityEnabled('asset_sync', configuration().capabilityAllowlist)) {
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);
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try {
const result = await synchronize(current.userId);
+11 -1
View File
@@ -1,3 +1,13 @@
import type { PageServerLoad } from './$types';
import { assetReport, session } from '$lib/server/application';
export const load: PageServerLoad = async (event) => ({ result: await assetReport((await session(event))?.userId ?? null, 'asset_search') });
import { configuration } from '$lib/server/config';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const load: PageServerLoad = async (event) => {
const config = configuration();
for (const capability of ['asset_search', 'asset_valuation'] as const) {
if (!capabilityEnabled(capability, config.capabilityAllowlist)) {
return { result: { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) } };
}
}
return { result: await assetReport((await session(event))?.userId ?? null, 'asset_search') };
};
+13 -7
View File
@@ -2,17 +2,23 @@ import type { PageServerLoad } from './$types';
import { charactersFor, session } from '$lib/server/application';
import { configuration } from '$lib/server/config';
import { capabilityEnabled, capabilityDisabledReason } from '$lib/server/release-gate';
export const load: PageServerLoad = async (event) => {
const config = configuration();
const identityEnabled = capabilityEnabled('identity', config.capabilityAllowlist);
if (!identityEnabled) {
return {
characters: [],
identityEnabled: false,
error: capabilityDisabledReason('identity'),
};
}
const current = await session(event);
return {
characters: identityEnabled ? await charactersFor(current?.userId ?? null) : [],
identityEnabled,
error: !identityEnabled
? capabilityDisabledReason('identity')
: event.url.searchParams.get('auth') === 'failed'
? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.'
: null,
characters: await charactersFor(current?.userId ?? null),
identityEnabled: true,
error: event.url.searchParams.get('auth') === 'failed'
? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.'
: null,
};
};