feat: add isolated release gates and CI checks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, string>, publicConfig: Environment, deployment = false): Environment {
|
||||
const env = cleanEnvironment(base);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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') });
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user