feat: add isolated release gates and CI checks

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 0ff3e67568
commit 92272d4e9a
20 changed files with 442 additions and 10 deletions
+4
View File
@@ -1,8 +1,12 @@
import type { Handle } from '@sveltejs/kit';
import { configuration } from './lib/server/config';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('cache-control', 'no-store');
response.headers.set('x-content-type-options', 'nosniff');
// The identifier is immutable build metadata, never a secret or an auth
// decision. It makes rollback/operator diagnostics auditable.
response.headers.set('x-application-release', configuration().releaseId);
// Native POST navigations under no-referrer send Origin: null, breaking
// strict CSRF checks. Ordinary pages preserve same-origin form Origin while
// suppressing cross-origin referrers; auth URLs (including callback codes)
+14 -2
View File
@@ -7,6 +7,7 @@ import { TokenCipher, hashSecret } from './security';
import { EveSso, type VerifiedGrant } from './sso';
import { EsiClient, EsiMarketEstimates } from './esi';
import { SqliteReferenceRepository } from './reference';
import { capabilityDisabledReason, capabilityEnabled, type ReleaseCapability } from './release-gate';
export const SESSION_COOKIE = 'eve_session';
export type Session = { userId: string; sessionId: string };
@@ -51,7 +52,14 @@ export async function charactersFor(userId: string | null): Promise<Character[]>
return userId ? repository().characters(userId) : [];
}
/** Both conventional browsing and the assistant enter here. A request pins one reference artifact. */
export async function assetReport(userId: string | null) {
export async function assetReport(
userId: string | null,
capability: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'> = 'asset_valuation',
) {
const config = configuration();
if (!capabilityEnabled(capability, config.capabilityAllowlist)) {
return { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) };
}
let reference: SqliteReferenceRepository | undefined;
const getReference = () => {
const config = configuration();
@@ -91,7 +99,11 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn
try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); }
}
export async function synchronize(userId: string) {
if (configuration().mode === 'demo') return { completed: 2, failed: [] as number[], demo: true };
const config = configuration();
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) {
throw new Error(capabilityDisabledReason('asset_sync'));
}
if (config.mode === 'demo') return { completed: 2, failed: [] as number[], demo: true };
const { store, esi } = live();
const characters = await store.characters(userId);
const failed: number[] = []; let completed = 0;
+4 -2
View File
@@ -1,12 +1,14 @@
import type { AssetOperation } from '$lib/domain/model';
import type { IntentDecision, IntentRouter } from './intent';
import type { ReleaseCapability } from '../release-gate';
export type AssistantResponse = { status: string; message: string; decision: IntentDecision; operation?: AssetOperation };
/** AI selects a closed-set intent; execution and access checks stay in the shared service. */
export async function answerAssets(input: { userId: string | null; message: string }, router: IntentRouter, report: (userId: string | null) => Promise<AssetOperation>): Promise<AssistantResponse> {
export async function answerAssets(input: { userId: string | null; message: string }, router: IntentRouter, report: (userId: string | null, capability?: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'>) => Promise<AssetOperation>): Promise<AssistantResponse> {
const decision = await router.classify(input.message);
if (decision.status !== 'routed') return { status: decision.status, message: decision.reason, decision };
if (decision.intent === 'unsupported') return { status: 'unsupported', message: 'This milestone supports browsing all connected-character assets and estimating their total reference value. Item/location filters, liquidation, industry and routes are not implemented. Use /assets or /value.', decision };
const operation = await report(input.userId);
const capability = decision.intent === 'asset_search' ? 'asset_search' : 'asset_valuation';
const operation = await report(input.userId, capability);
const messages = {
ready: 'Here are all connected characters’ assets with their reference-price estimate. This is not an executable sale quote.',
authentication_required: 'Connect an EVE character to continue. Your original request can resume afterward.',
+5
View File
@@ -1,12 +1,17 @@
import { resolve } from 'node:path';
import { parseCapabilityAllowlist, validReleaseId } from './release-gate';
export type AppConfig = ReturnType<typeof configuration>;
export function configuration() {
const mode = process.env.APP_MODE ?? 'live';
if (mode !== 'live' && mode !== 'demo') throw new Error('APP_MODE must be live or demo');
const origin = process.env.APP_ORIGIN ?? 'http://localhost:5173';
if (new URL(origin).origin !== origin) throw new Error('APP_ORIGIN must be an origin without a trailing slash or path');
const releaseId = process.env.APP_RELEASE_ID ?? 'local';
if (!validReleaseId(releaseId)) throw new Error('APP_RELEASE_ID contains invalid release metadata');
return {
mode, origin,
releaseId,
capabilityAllowlist: parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST),
referenceDir: resolve(process.env.REFERENCE_DIR ?? 'data/reference'),
convexUrl: process.env.CONVEX_URL ?? process.env.CONVEX_SELF_HOSTED_URL ?? '',
publicConvexUrl: process.env.PUBLIC_CONVEX_URL ?? '',
+102
View File
@@ -0,0 +1,102 @@
/**
* Deployment-time rollout controls. These are deliberately separate from the
* domain capability contracts: a capability may have a complete contract and
* still remain disabled in a particular release.
*
* The allowlist is evaluated on the server for every entry point. Unknown
* names are rejected instead of becoming an accidental opt-in, and newly
* added known capabilities are absent from the baseline allowlist until an
* operator explicitly enables them.
*/
export const RELEASE_CAPABILITIES = [
'asset_search',
'asset_valuation',
'asset_sync',
'assistant_dispatch',
'session_token',
'subscriptions',
'wallet_balance',
'inventory_export',
'manufacturing_opportunities',
] as const;
export type ReleaseCapability = (typeof RELEASE_CAPABILITIES)[number];
/** Existing first-slice behavior remains enabled unless an operator narrows it. */
export const BASELINE_RELEASE_CAPABILITIES: readonly ReleaseCapability[] = [
'asset_search',
'asset_valuation',
'asset_sync',
'assistant_dispatch',
'session_token',
];
const known = new Set<string>(RELEASE_CAPABILITIES);
export class CapabilityConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'CapabilityConfigurationError';
}
}
/**
* Parse APP_CAPABILITY_ALLOWLIST without accepting typos or duplicate policy
* entries. An explicitly empty value disables every capability; an omitted
* value preserves the existing private asset/sign-in baseline.
*/
export function parseCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] {
if (raw === undefined) return [...BASELINE_RELEASE_CAPABILITIES];
const value = raw.trim();
if (value === '') return [];
const result: ReleaseCapability[] = [];
const seen = new Set<string>();
for (const token of value.split(',')) {
const name = token.trim();
if (!name || !known.has(name)) {
throw new CapabilityConfigurationError(`Unknown deployment capability: ${name || '<empty>'}`);
}
if (!seen.has(name)) {
seen.add(name);
result.push(name as ReleaseCapability);
}
}
return result;
}
export function capabilityEnabled(
capability: ReleaseCapability,
allowlist: readonly ReleaseCapability[],
): boolean {
return allowlist.includes(capability);
}
export function capabilityDisabledReason(capability: ReleaseCapability): string {
return `The ${capability} capability is disabled by deployment configuration.`;
}
/** A non-throwing check is useful at HTTP boundaries that should fail closed. */
export function requireCapability(
capability: ReleaseCapability,
allowlist: readonly ReleaseCapability[],
): void {
if (!capabilityEnabled(capability, allowlist)) {
throw new CapabilityConfigurationError(capabilityDisabledReason(capability));
}
}
/** Release identifiers are metadata, not a source of authorization. */
export function validReleaseId(value: string): boolean {
return /^(?!latest$)[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
}
/**
* Production images must carry a non-placeholder immutable identifier. Local
* development and fixture runs intentionally use `local`.
*/
export function releaseIdIsImmutable(value: string, environment = process.env.NODE_ENV): boolean {
if (!validReleaseId(value)) return false;
if (environment !== 'production') return true;
return value !== 'local' && value !== 'dev' && value !== 'development' && value !== 'unreleased';
}
+6
View File
@@ -6,6 +6,7 @@ import { configuration } from '$lib/server/config';
import { boundedJson, protectMutation } from '$lib/server/http';
import { JevIntentRouter, ManualIntentRouter } from '$lib/server/assistant/intent';
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);
@@ -14,6 +15,11 @@ export const POST: RequestHandler = async (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.
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
}
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 -1
View File
@@ -3,8 +3,11 @@ import type { RequestHandler } from './$types';
import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application';
import { applicationJwt } from '$lib/server/security';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const GET: RequestHandler = async (event) => {
const config = configuration(); const current = await session(event);
const config = configuration();
if (!capabilityEnabled('session_token', config.capabilityAllowlist)) return json({ error: capabilityDisabledReason('session_token') }, { status: 503 });
const current = await session(event);
if (!current || config.mode === 'demo') return json({ error: 'Sign in required' }, { status: 401 });
const token = await applicationJwt({ ...current, issuer: config.issuer, privateKey: config.privateKey });
return json({ token }, { headers: { 'cache-control': 'no-store' } });
+5
View File
@@ -2,9 +2,14 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { session, synchronize } from '$lib/server/application';
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)) {
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
}
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try {
const result = await synchronize(current.userId);
+1 -1
View File
@@ -1,3 +1,3 @@
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) });
export const load: PageServerLoad = async (event) => ({ result: await assetReport((await session(event))?.userId ?? null, 'asset_search') });