fix: fail closed backend capabilities and isolate gate smoke
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user