From 92272d4e9af3b5132c153f44398232a3ad897403 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 18 Sep 2026 23:44:47 -0400 Subject: [PATCH] feat: add isolated release gates and CI checks --- .env.example | 6 ++ .gitea/workflows/ci.yml | 28 +++++++ Dockerfile | 3 +- compose.yaml | 2 + config/dev.env | 3 + docs/release-safety.md | 77 ++++++++++++++++++ package.json | 4 +- scripts/fixture-smoke.ts | 68 ++++++++++++++++ scripts/lib/infisical.ts | 4 +- scripts/release-check.ts | 60 ++++++++++++++ src/hooks.server.ts | 4 + src/lib/server/application.ts | 16 +++- src/lib/server/assistant/service.ts | 6 +- src/lib/server/config.ts | 5 ++ src/lib/server/release-gate.ts | 102 ++++++++++++++++++++++++ src/routes/api/chat/+server.ts | 6 ++ src/routes/api/session/token/+server.ts | 5 +- src/routes/api/sync/+server.ts | 5 ++ src/routes/assets/+page.server.ts | 2 +- tests/release-safety.test.ts | 46 +++++++++++ 20 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 docs/release-safety.md create mode 100644 scripts/fixture-smoke.ts create mode 100644 scripts/release-check.ts create mode 100644 src/lib/server/release-gate.ts create mode 100644 tests/release-safety.test.ts diff --git a/.env.example b/.env.example index ee4aec2..56d5995 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Default is live. Demo is explicit and visibly labeled; never used as an error fallback. APP_MODE=live +# Immutable image/application identifier in staging/production; local is for development only. +APP_RELEASE_ID=local +# Comma-separated server-side rollout allowlist. New capabilities are off by default. +APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token +RELEASE_ENV=development APP_ORIGIN=http://localhost:5173 APP_AUTH_ISSUER=http://localhost:5173 REFERENCE_DIR=./data/reference @@ -39,3 +44,4 @@ CONVEX_SITE_ORIGIN=http://localhost:3211 # Pin the optional dashboard too before production use. CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard:latest +# Production release checks require this optional admin image to be digest-pinned. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..92cb3d2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,28 @@ +name: isolated-ci + +on: + push: + pull_request: + +jobs: + verify: + # Runner labels/capabilities must be confirmed by the operator before this + # workflow is considered release evidence. No deployment or secret access is + # performed by this job. + runs-on: ubuntu-latest + container: oven/bun:1.3.14 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install frozen dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Unit and integration tests + run: bun test + - name: Typecheck + run: bun run check + - name: Build fixture application + run: APP_MODE=demo APP_RELEASE_ID=fixture bun run build + - name: Release safety checks + run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check + - name: Isolated fixture smoke + run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture diff --git a/Dockerfile b/Dockerfile index b15f32b..0a4d173 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,11 @@ COPY . . RUN bun run build FROM oven/bun:1.3.14 AS runtime +ARG APP_RELEASE_ID=local # Production reference imports stream XZ through the OS decoder; no archive extraction shell. RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/* WORKDIR /app -ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 APP_RELEASE_ID=$APP_RELEASE_ID COPY --from=build /app/package.json /app/bun.lock ./ RUN bun install --frozen-lockfile --production COPY --from=build /app/build ./build diff --git a/compose.yaml b/compose.yaml index a726dc2..6dc006b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -35,6 +35,8 @@ services: ports: ["${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-3000}:3000"] environment: APP_MODE: ${APP_MODE:-live} + APP_RELEASE_ID: ${APP_RELEASE_ID:-local} + APP_CAPABILITY_ALLOWLIST: ${APP_CAPABILITY_ALLOWLIST-asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token} ORIGIN: ${APP_ORIGIN:-http://localhost:3000} APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000} CONVEX_URL: http://backend:3210 diff --git a/config/dev.env b/config/dev.env index e2fc7ea..edf8326 100644 --- a/config/dev.env +++ b/config/dev.env @@ -1,5 +1,8 @@ # Public deployment configuration only. Credentials come from Infisical. APP_MODE=live +APP_RELEASE_ID=local +APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token +RELEASE_ENV=development APP_ORIGIN=https://dev.evebot.app APP_AUTH_ISSUER=https://dev.evebot.app EVE_CALLBACK_URL=https://dev.evebot.app/auth/eve/callback diff --git a/docs/release-safety.md b/docs/release-safety.md new file mode 100644 index 0000000..821952d --- /dev/null +++ b/docs/release-safety.md @@ -0,0 +1,77 @@ +# Release and rollout safety (T01) + +This repository uses Gitea Actions for CI. The workflow in `.gitea/workflows/ci.yml` +only installs the frozen lockfile, runs local tests/check/build, compiles the +checked-in synthetic reference fixture, and runs an isolated demo web smoke. +It does not receive deployment credentials, trigger a remote deployment, or +write a persistent Convex backend. + +## Server-side rollout gate + +`APP_CAPABILITY_ALLOWLIST` is a comma-separated deployment setting parsed by +`src/lib/server/release-gate.ts`. An omitted setting preserves the existing +private asset/sign-in baseline (`asset_search`, `asset_valuation`, +`asset_sync`, `assistant_dispatch`, and `session_token`). An explicitly empty +setting disables all capabilities. Unknown names fail configuration rather +than becoming an accidental opt-in; known capabilities that are not in the +baseline (for example `wallet_balance`, `inventory_export`, and +`subscriptions`) are default-deny. + +The web server checks the allowlist at the relevant boundary: + +- `/api/chat` checks `assistant_dispatch` before invoking manual or external + intent routing. +- `/assets` and assistant execution check the selected asset capability. +- `/api/sync` and the application synchronizer check `asset_sync`. +- `/api/session/token` checks `session_token`. + +This is an admission control mechanism, not a UI feature flag. Disabled +operations return an explicit unavailable/503 response and do not invoke a +provider. No subscription or durable user-job service exists in this baseline; +their capability names remain closed until those services have a reviewed +entry point. + +## Immutable release metadata + +`APP_RELEASE_ID` is carried into the runtime image and returned as the +non-secret `x-application-release` response header. Local/fixture runs use +`local` or `fixture`. Staging and production must provide a non-placeholder +identifier (normally the source commit or an operator-approved image digest). +`bun run release:check` rejects placeholder staging/production identifiers, +unpinned staging/production dashboard images, demo mode in staging/production, +invalid capability names, and tracked secret-like files. The Dockerfile accepts +`--build-arg APP_RELEASE_ID=...`; Compose passes the runtime value explicitly. + +## Isolated local validation + +Use a separate worktree-local output directory and a unique Compose project +name for backend experiments. Never point these commands at a persistent +backend or copy a live `.env`/volume: + +```sh +APP_MODE=demo APP_RELEASE_ID=fixture bun run build +APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check +APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture +``` + +`test:fixture` compiles the checked-in reference sources into a temporary +folder, starts the built server on loopback with synthetic data, checks the +release header and matching `/assets`/chat totals, then kills the server and +removes the temporary artifact directory. + +For an isolated Compose rehearsal, set `COMPOSE_PROJECT_NAME` to a unique +throwaway value, keep all binds on loopback, use a fixture/demo environment, +and remove the project only after recording the result. The release gate does +not itself perform migrations, backups, restores, or cleanup of an operator's +persistent deployment. + +## Evidence boundaries + +Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs +for `jb/eden`; runner labels and action availability are therefore not +validated. The workflow is prepared locally but has not run remotely. +No browser automation, OAuth callback failure injection, Convex migration +rollback, instance-credential backup/restore, key rotation, or live +revocation/transfer evidence is claimed by this lane. Those remain explicit +operator/integration gates and must use synthetic or separately consented +isolated environments. diff --git a/package.json b/package.json index 7ec3672..99ff8da 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "convex:configure": "bun scripts/configure-convex.ts", "keys:generate": "bun scripts/generate-keys.ts", "secrets:status": "bun scripts/secrets.ts status", - "secrets:run": "bun scripts/secrets.ts run" + "secrets:run": "bun scripts/secrets.ts run", + "release:check": "bun scripts/release-check.ts", + "test:fixture": "bun scripts/fixture-smoke.ts" }, "dependencies": { "@sveltejs/kit": "2.70.3", diff --git a/scripts/fixture-smoke.ts b/scripts/fixture-smoke.ts new file mode 100644 index 0000000..9e254bd --- /dev/null +++ b/scripts/fixture-smoke.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const port = Number(process.env.SMOKE_WEB_PORT ?? 15173); +const base = `http://127.0.0.1:${port}`; +const root = await mkdtemp(join(tmpdir(), 'eden-fixture-')); +let server: Bun.Subprocess | undefined; + +async function compileFixture(directory: string): Promise { + const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], { + stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', + env: { ...process.env, APP_MODE: 'demo', REFERENCE_DIR: directory }, + }); + const code = await child.exited; + if (code !== 0) throw new Error('Fixture reference compilation failed'); +} + +async function waitForServer(): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + const response = await fetch(base); + if (response.status === 200) return; + } catch { /* startup race */ } + await Bun.sleep(250); + } + throw new Error('Fixture web server did not become ready'); +} + +try { + await compileFixture(root); + server = Bun.spawn(['bun', 'build/index.js'], { + stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', + env: { + ...process.env, + APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base, + HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root, + APP_RELEASE_ID: 'fixture', + }, + }); + await waitForServer(); + + const rootResponse = await fetch(base); + if (!(await rootResponse.text()).includes('FIXTURE DEMO')) throw new Error('Fixture label missing'); + if (rootResponse.headers.get('x-application-release') !== 'fixture') throw new Error('Release identifier header missing'); + const assets = await fetch(`${base}/assets`); + if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed'); + + for (const message of ['/value', '/assets']) { + const response = await fetch(`${base}/api/chat`, { + method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, + body: JSON.stringify({ message, requestId: crypto.randomUUID() }), + }); + if (response.status !== 200) throw new Error(`Fixture assistant failed for ${message}`); + const result = await response.json() as { status?: string; operation?: { status?: string; report?: { total?: string } } }; + if (result.status !== 'ready' || result.operation?.status !== 'ready' || result.operation.report?.total !== '208600000') { + throw new Error(`Fixture assistant returned an unexpected result for ${message}`); + } + } + console.log(`Isolated fixture smoke passed: ${base}; synthetic reference/backend only`); +} finally { + if (server) { + server.kill('SIGTERM'); + await server.exited; + } + await rm(root, { recursive: true, force: true }); +} diff --git a/scripts/lib/infisical.ts b/scripts/lib/infisical.ts index ac9d44c..4598b77 100644 --- a/scripts/lib/infisical.ts +++ b/scripts/lib/infisical.ts @@ -152,10 +152,10 @@ export function cleanEnvironment(base: Environment): Environment { } const PUBLIC_KEYS = new Set([ - 'APP_MODE', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL', + 'APP_MODE', 'APP_RELEASE_ID', 'APP_CAPABILITY_ALLOWLIST', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL', 'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT', 'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT', - 'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE', + 'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE', 'RELEASE_ENV', ]); export function applicationEnvironment(base: Environment, secrets: Record, publicConfig: Environment, deployment = false): Environment { const env = cleanEnvironment(base); diff --git a/scripts/release-check.ts b/scripts/release-check.ts new file mode 100644 index 0000000..f0c2125 --- /dev/null +++ b/scripts/release-check.ts @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; +import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate'; + +type ReleaseCheckInput = { + mode: string; + releaseId: string; + releaseEnvironment: string; + dashboardImage: string; + capabilityAllowlist: string | undefined; + trackedFiles: readonly string[]; +}; + +export function releaseSafetyErrors(input: ReleaseCheckInput): string[] { + const errors: string[] = []; + try { parseCapabilityAllowlist(input.capabilityAllowlist); } + catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); } + + const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging'; + if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) { + errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment'); + } + if (strictEnvironment && !input.dashboardImage.includes('@sha256:')) { + errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production'); + } + if (input.mode === 'demo' && strictEnvironment) { + errors.push('APP_MODE=demo is not permitted for a staging/production release'); + } + for (const file of input.trackedFiles) { + if (/(^|\/)(?:\.env(?:\.|$)|.*\.pem$|.*\.key$)/i.test(file) && file !== '.env.example') { + errors.push(`secret-like file is tracked: ${file}`); + } + } + return errors; +} + +function trackedFiles(): string[] { + const result = spawnSync('git', ['ls-files'], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error('Unable to inspect tracked files'); + return result.stdout.split('\n').filter(Boolean); +} + +if (import.meta.main) { + const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : 'development'); + const errors = releaseSafetyErrors({ + mode: process.env.APP_MODE ?? 'live', + releaseId: process.env.APP_RELEASE_ID ?? 'local', + releaseEnvironment, + dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '', + capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST, + trackedFiles: trackedFiles(), + }); + if (errors.length) { + console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`); + for (const message of errors) console.error(`- ${message}`); + process.exitCode = 1; + } else { + const capabilities = parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST).join(',') || 'none'; + console.log(`Release safety check passed: environment=${releaseEnvironment}, mode=${process.env.APP_MODE ?? 'live'}, capabilities=${capabilities}`); + } +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e59911b..269d528 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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) diff --git a/src/lib/server/application.ts b/src/lib/server/application.ts index f3e2ddf..47d6546 100644 --- a/src/lib/server/application.ts +++ b/src/lib/server/application.ts @@ -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 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 = '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; diff --git a/src/lib/server/assistant/service.ts b/src/lib/server/assistant/service.ts index 4d778e0..5622308 100644 --- a/src/lib/server/assistant/service.ts +++ b/src/lib/server/assistant/service.ts @@ -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): Promise { +export async function answerAssets(input: { userId: string | null; message: string }, router: IntentRouter, report: (userId: string | null, capability?: Extract) => Promise): Promise { 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.', diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index be1613e..a593847 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -1,12 +1,17 @@ import { resolve } from 'node:path'; +import { parseCapabilityAllowlist, validReleaseId } from './release-gate'; export type AppConfig = ReturnType; 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 ?? '', diff --git a/src/lib/server/release-gate.ts b/src/lib/server/release-gate.ts new file mode 100644 index 0000000..1ffd992 --- /dev/null +++ b/src/lib/server/release-gate.ts @@ -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(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(); + for (const token of value.split(',')) { + const name = token.trim(); + if (!name || !known.has(name)) { + throw new CapabilityConfigurationError(`Unknown deployment capability: ${name || ''}`); + } + 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'; +} diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index 423f077..fc1a379 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -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 }); diff --git a/src/routes/api/session/token/+server.ts b/src/routes/api/session/token/+server.ts index e0ac169..b64fc8b 100644 --- a/src/routes/api/session/token/+server.ts +++ b/src/routes/api/session/token/+server.ts @@ -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' } }); diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index ed03121..fe7091b 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -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); diff --git a/src/routes/assets/+page.server.ts b/src/routes/assets/+page.server.ts index eaec7e7..53683e8 100644 --- a/src/routes/assets/+page.server.ts +++ b/src/routes/assets/+page.server.ts @@ -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') }); diff --git a/tests/release-safety.test.ts b/tests/release-safety.test.ts new file mode 100644 index 0000000..b5b3839 --- /dev/null +++ b/tests/release-safety.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import { + BASELINE_RELEASE_CAPABILITIES, + capabilityEnabled, + parseCapabilityAllowlist, + releaseIdIsImmutable, +} from '../src/lib/server/release-gate'; +import { releaseSafetyErrors } from '../scripts/release-check'; + +describe('deployment rollout gate', () => { + test('keeps the existing private baseline and defaults new capabilities off', () => { + const baseline = parseCapabilityAllowlist(undefined); + expect(baseline).toEqual(BASELINE_RELEASE_CAPABILITIES); + expect(capabilityEnabled('asset_search', baseline)).toBe(true); + expect(capabilityEnabled('wallet_balance', baseline)).toBe(false); + expect(capabilityEnabled('subscriptions', baseline)).toBe(false); + }); + + test('supports explicit narrowing and rejects unknown or empty names', () => { + expect(parseCapabilityAllowlist('asset_search, asset_search,session_token')).toEqual(['asset_search', 'session_token']); + expect(parseCapabilityAllowlist('')).toEqual([]); + expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability'); + expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability'); + }); + + test('requires a non-placeholder production release identifier', () => { + expect(releaseIdIsImmutable('local', 'production')).toBe(false); + expect(releaseIdIsImmutable('abc1234', 'production')).toBe(true); + expect(releaseIdIsImmutable('latest', 'production')).toBe(false); + expect(releaseIdIsImmutable('local', 'development')).toBe(true); + }); + + test('release check catches unsafe production inputs without printing values', () => { + const errors = releaseSafetyErrors({ + mode: 'demo', releaseId: 'local', releaseEnvironment: 'production', dashboardImage: 'convex-dashboard:latest', + capabilityAllowlist: 'wallet_balance,unknown', trackedFiles: ['.env.example', 'secrets/app.pem'], + }); + expect(errors).toEqual(expect.arrayContaining([ + 'APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment', + 'CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production', + 'APP_MODE=demo is not permitted for a staging/production release', + 'Unknown deployment capability: unknown', + 'secret-like file is tracked: secrets/app.pem', + ])); + }); +});