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
+6
View File
@@ -1,5 +1,10 @@
# Default is live. Demo is explicit and visibly labeled; never used as an error fallback. # Default is live. Demo is explicit and visibly labeled; never used as an error fallback.
APP_MODE=live 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_ORIGIN=http://localhost:5173
APP_AUTH_ISSUER=http://localhost:5173 APP_AUTH_ISSUER=http://localhost:5173
REFERENCE_DIR=./data/reference REFERENCE_DIR=./data/reference
@@ -39,3 +44,4 @@ CONVEX_SITE_ORIGIN=http://localhost:3211
# Pin the optional dashboard too before production use. # Pin the optional dashboard too before production use.
CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard:latest CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard:latest
# Production release checks require this optional admin image to be digest-pinned.
+28
View File
@@ -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
+2 -1
View File
@@ -7,10 +7,11 @@ COPY . .
RUN bun run build RUN bun run build
FROM oven/bun:1.3.14 AS runtime 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. # 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/* RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/*
WORKDIR /app 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 ./ COPY --from=build /app/package.json /app/bun.lock ./
RUN bun install --frozen-lockfile --production RUN bun install --frozen-lockfile --production
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
+2
View File
@@ -35,6 +35,8 @@ services:
ports: ["${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-3000}:3000"] ports: ["${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-3000}:3000"]
environment: environment:
APP_MODE: ${APP_MODE:-live} 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} ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000} APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
CONVEX_URL: http://backend:3210 CONVEX_URL: http://backend:3210
+3
View File
@@ -1,5 +1,8 @@
# Public deployment configuration only. Credentials come from Infisical. # Public deployment configuration only. Credentials come from Infisical.
APP_MODE=live 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_ORIGIN=https://dev.evebot.app
APP_AUTH_ISSUER=https://dev.evebot.app APP_AUTH_ISSUER=https://dev.evebot.app
EVE_CALLBACK_URL=https://dev.evebot.app/auth/eve/callback EVE_CALLBACK_URL=https://dev.evebot.app/auth/eve/callback
+77
View File
@@ -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.
+3 -1
View File
@@ -17,7 +17,9 @@
"convex:configure": "bun scripts/configure-convex.ts", "convex:configure": "bun scripts/configure-convex.ts",
"keys:generate": "bun scripts/generate-keys.ts", "keys:generate": "bun scripts/generate-keys.ts",
"secrets:status": "bun scripts/secrets.ts status", "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": { "dependencies": {
"@sveltejs/kit": "2.70.3", "@sveltejs/kit": "2.70.3",
+68
View File
@@ -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<void> {
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<void> {
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 });
}
+2 -2
View File
@@ -152,10 +152,10 @@ export function cleanEnvironment(base: Environment): Environment {
} }
const PUBLIC_KEYS = new Set([ 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', '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', '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<string, string>, publicConfig: Environment, deployment = false): Environment { export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
const env = cleanEnvironment(base); const env = cleanEnvironment(base);
+60
View File
@@ -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}`);
}
}
+4
View File
@@ -1,8 +1,12 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { configuration } from './lib/server/config';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event); const response = await resolve(event);
response.headers.set('cache-control', 'no-store'); response.headers.set('cache-control', 'no-store');
response.headers.set('x-content-type-options', 'nosniff'); 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 // Native POST navigations under no-referrer send Origin: null, breaking
// strict CSRF checks. Ordinary pages preserve same-origin form Origin while // strict CSRF checks. Ordinary pages preserve same-origin form Origin while
// suppressing cross-origin referrers; auth URLs (including callback codes) // 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 { EveSso, type VerifiedGrant } from './sso';
import { EsiClient, EsiMarketEstimates } from './esi'; import { EsiClient, EsiMarketEstimates } from './esi';
import { SqliteReferenceRepository } from './reference'; import { SqliteReferenceRepository } from './reference';
import { capabilityDisabledReason, capabilityEnabled, type ReleaseCapability } from './release-gate';
export const SESSION_COOKIE = 'eve_session'; export const SESSION_COOKIE = 'eve_session';
export type Session = { userId: string; sessionId: string }; 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) : []; return userId ? repository().characters(userId) : [];
} }
/** Both conventional browsing and the assistant enter here. A request pins one reference artifact. */ /** 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; let reference: SqliteReferenceRepository | undefined;
const getReference = () => { const getReference = () => {
const config = configuration(); 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); } try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); }
} }
export async function synchronize(userId: string) { 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 { store, esi } = live();
const characters = await store.characters(userId); const characters = await store.characters(userId);
const failed: number[] = []; let completed = 0; const failed: number[] = []; let completed = 0;
+4 -2
View File
@@ -1,12 +1,14 @@
import type { AssetOperation } from '$lib/domain/model'; import type { AssetOperation } from '$lib/domain/model';
import type { IntentDecision, IntentRouter } from './intent'; import type { IntentDecision, IntentRouter } from './intent';
import type { ReleaseCapability } from '../release-gate';
export type AssistantResponse = { status: string; message: string; decision: IntentDecision; operation?: AssetOperation }; 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. */ /** 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); const decision = await router.classify(input.message);
if (decision.status !== 'routed') return { status: decision.status, message: decision.reason, decision }; 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 }; 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 = { const messages = {
ready: 'Here are all connected characters’ assets with their reference-price estimate. This is not an executable sale quote.', 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.', 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 { resolve } from 'node:path';
import { parseCapabilityAllowlist, validReleaseId } from './release-gate';
export type AppConfig = ReturnType<typeof configuration>; export type AppConfig = ReturnType<typeof configuration>;
export function configuration() { export function configuration() {
const mode = process.env.APP_MODE ?? 'live'; const mode = process.env.APP_MODE ?? 'live';
if (mode !== 'live' && mode !== 'demo') throw new Error('APP_MODE must be live or demo'); if (mode !== 'live' && mode !== 'demo') throw new Error('APP_MODE must be live or demo');
const origin = process.env.APP_ORIGIN ?? 'http://localhost:5173'; 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'); 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 { return {
mode, origin, mode, origin,
releaseId,
capabilityAllowlist: parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST),
referenceDir: resolve(process.env.REFERENCE_DIR ?? 'data/reference'), referenceDir: resolve(process.env.REFERENCE_DIR ?? 'data/reference'),
convexUrl: process.env.CONVEX_URL ?? process.env.CONVEX_SELF_HOSTED_URL ?? '', convexUrl: process.env.CONVEX_URL ?? process.env.CONVEX_SELF_HOSTED_URL ?? '',
publicConvexUrl: process.env.PUBLIC_CONVEX_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 { boundedJson, protectMutation } from '$lib/server/http';
import { JevIntentRouter, ManualIntentRouter } from '$lib/server/assistant/intent'; import { JevIntentRouter, ManualIntentRouter } from '$lib/server/assistant/intent';
import { answerAssets } from '$lib/server/assistant/service'; 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() }); const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const current = await session(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 }); if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
const { message, requestId } = parsed.data; const { message, requestId } = parsed.data;
const config = configuration(); 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(); const manual = new ManualIntentRouter();
// Explicit commands remain reliable even when JEV is configured/unavailable. // Explicit commands remain reliable even when JEV is configured/unavailable.
const router = message === '/assets' || message === '/value' || !config.jevKey ? manual : new JevIntentRouter({ apiKey: config.jevKey }); 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 { configuration } from '$lib/server/config';
import { session } from '$lib/server/application'; import { session } from '$lib/server/application';
import { applicationJwt } from '$lib/server/security'; import { applicationJwt } from '$lib/server/security';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const GET: RequestHandler = async (event) => { 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 }); if (!current || config.mode === 'demo') return json({ error: 'Sign in required' }, { status: 401 });
const token = await applicationJwt({ ...current, issuer: config.issuer, privateKey: config.privateKey }); const token = await applicationJwt({ ...current, issuer: config.issuer, privateKey: config.privateKey });
return json({ token }, { headers: { 'cache-control': 'no-store' } }); 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 type { RequestHandler } from './$types';
import { session, synchronize } from '$lib/server/application'; import { session, synchronize } from '$lib/server/application';
import { protectMutation } from '$lib/server/http'; 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) => { export const POST: RequestHandler = async (event) => {
const current = await session(event); const current = await session(event);
protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6); 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 }); if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try { try {
const result = await synchronize(current.userId); const result = await synchronize(current.userId);
+1 -1
View File
@@ -1,3 +1,3 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { assetReport, session } from '$lib/server/application'; 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') });
+46
View File
@@ -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',
]));
});
});