feat: add isolated release gates and CI checks
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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,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') });
|
||||
|
||||
Reference in New Issue
Block a user