From 99436225f620f942b4f63ecb37839100de1db458 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 18 Sep 2026 23:51:48 -0400 Subject: [PATCH] fix: enforce rollout gates across backend entry points --- .env.example | 2 +- compose.yaml | 2 +- config/dev.env | 2 +- docs/release-safety.md | 20 +++++++++++++------ scripts/configure-convex.ts | 8 +++++--- src/convex/functions.ts | 15 ++++++++++++++ src/convex/status.ts | 12 ++++++++++++ src/hooks.server.ts | 5 ++++- src/lib/server/application.ts | 10 +++++++--- src/lib/server/release-gate.ts | 3 +++ src/routes/auth/eve/+server.ts | 2 ++ src/routes/auth/eve/callback/+server.ts | 6 +++++- src/routes/characters/+page.server.ts | 20 +++++++++++++++---- src/routes/characters/+page.svelte | 2 +- tests/convex.test.ts | 26 +++++++++++++++++++++++++ tests/release-safety.test.ts | 3 ++- 16 files changed, 115 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 56d5995..85bb471 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ 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 +APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions RELEASE_ENV=development APP_ORIGIN=http://localhost:5173 APP_AUTH_ISSUER=http://localhost:5173 diff --git a/compose.yaml b/compose.yaml index 6dc006b..961700e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -36,7 +36,7 @@ services: 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} + APP_CAPABILITY_ALLOWLIST: ${APP_CAPABILITY_ALLOWLIST-asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions} 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 edf8326..9fd13a1 100644 --- a/config/dev.env +++ b/config/dev.env @@ -1,7 +1,7 @@ # 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 +APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions RELEASE_ENV=development APP_ORIGIN=https://dev.evebot.app APP_AUTH_ISSUER=https://dev.evebot.app diff --git a/docs/release-safety.md b/docs/release-safety.md index 821952d..ade5068 100644 --- a/docs/release-safety.md +++ b/docs/release-safety.md @@ -11,25 +11,33 @@ write a persistent Convex backend. `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 +`asset_sync`, `assistant_dispatch`, `session_token`, `identity`, and +`subscriptions`). 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. +`manufacturing_opportunities`) 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. +- `/assets` and assistant execution check the selected asset capability plus + `asset_valuation`, because the current shared report intentionally returns a + reference valuation even for browse results. - `/api/sync` and the application synchronizer check `asset_sync`. - `/api/session/token` checks `session_token`. +- Character management and OAuth entry points check `identity`. +- The Convex browser status subscription checks `subscriptions`; sync bridge + mutations check `asset_sync` in the backend authorization plane too. The + allowlist is copied into Convex by `bun run convex:configure`; that setting + is required there rather than inferred from the web container. 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. +provider. The current character status subscription is gated in Convex; no +durable user-job service exists in this baseline, so future job kinds remain +closed until they have reviewed enqueue and worker entry points. ## Immutable release metadata diff --git a/scripts/configure-convex.ts b/scripts/configure-convex.ts index 91e7f6d..40bf7bf 100644 --- a/scripts/configure-convex.ts +++ b/scripts/configure-convex.ts @@ -1,10 +1,12 @@ export {}; // Run only against your intended self-hosted deployment. Values are never printed. -const names = ['CONVEX_BRIDGE_SECRET', 'APP_AUTH_ISSUER', 'APP_AUTH_JWKS'] as const; +const requiredNames = ['CONVEX_BRIDGE_SECRET', 'APP_AUTH_ISSUER', 'APP_AUTH_JWKS', 'APP_CAPABILITY_ALLOWLIST'] as const; if (!process.env.CONVEX_SELF_HOSTED_URL || !process.env.CONVEX_SELF_HOSTED_ADMIN_KEY) throw new Error('Set self-hosted URL and admin key before configuring Convex.'); -for (const name of names) { +for (const name of requiredNames) { const value = process.env[name]; - if (!value) throw new Error(`Missing ${name}`); + if (value === undefined || (name !== 'APP_CAPABILITY_ALLOWLIST' && !value)) { + throw new Error(`Missing ${name}`); + } // Pipe values rather than exposing them in process arguments or shell history. const child = Bun.spawn(['bun', 'node_modules/convex/bin/main.js', 'env', 'set', name], { stdin: new Blob([value]), stdout: 'pipe', stderr: 'pipe' }); if (await child.exited !== 0) throw new Error(`Could not configure ${name}; check deployment URL/admin key (output suppressed to protect secrets).`); diff --git a/src/convex/functions.ts b/src/convex/functions.ts index 15ff8f1..bc8e3fd 100644 --- a/src/convex/functions.ts +++ b/src/convex/functions.ts @@ -5,6 +5,7 @@ import { } from "convex/server"; import { v } from "convex/values"; import { assetValidator } from "./schema"; +import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate"; const MAX_ASSET_CHUNK = 250; const SYNC_LEASE_MS = 5 * 60 * 1000; @@ -39,6 +40,16 @@ function assertBridge(secret: string): void { } } +function assertDeploymentCapability(capability: 'asset_sync'): void { + let enabled = false; + try { + enabled = capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); + } catch { + // Invalid backend rollout configuration fails closed. + } + if (!enabled) throw new Error(`The ${capability} capability is disabled by deployment configuration.`); +} + function constantTimeEqual(a: string, b: string): boolean { let difference = a.length ^ b.length; const length = Math.max(a.length, b.length); @@ -412,6 +423,7 @@ export const bridgeBeginSync = mutationGeneric({ args: { ...bridgeArgs, ...characterArgs }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + assertDeploymentCapability('asset_sync'); const character = await findCharacter(ctx, args.userId, args.characterId); if (!character) throw new Error("Character not found"); const timestamp = now(); @@ -446,6 +458,7 @@ export const bridgeStageAssets = mutationGeneric({ }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + assertDeploymentCapability('asset_sync'); if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) { throw new Error("Invalid asset chunk"); } @@ -511,6 +524,7 @@ export const bridgeFinishSync = mutationGeneric({ }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + assertDeploymentCapability('asset_sync'); const timestamp = now(); if ( !Number.isFinite(args.syncedAt) || @@ -574,6 +588,7 @@ export const bridgeFailSync = mutationGeneric({ args: { ...bridgeArgs, ...characterArgs, generation: v.string(), error: v.string() }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); + assertDeploymentCapability('asset_sync'); const character = await findCharacter(ctx, args.userId, args.characterId); if (!character || character.syncGeneration !== args.generation) { throw new Error("Synchronization generation is not current"); diff --git a/src/convex/status.ts b/src/convex/status.ts index a7929e0..5413f6a 100644 --- a/src/convex/status.ts +++ b/src/convex/status.ts @@ -1,6 +1,7 @@ import { queryGeneric } from "convex/server"; import { v } from "convex/values"; import { characterStateValidator } from "./schema"; +import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate"; const safeAssetSyncValidator = v.object({ status: characterStateValidator, @@ -24,6 +25,16 @@ function isUsableSession(session: any, timestamp = Date.now()): boolean { ); } +function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean { + try { + return capabilityEnabled(capability, parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST)); + } catch { + // A malformed backend setting must fail closed, not expose a reactive + // tenant projection while the web process reports a configuration error. + return false; + } +} + /** * Browser-facing character status. Convex validates RS256 issuer/audience * through auth.config.ts; the checks here make the application boundary and @@ -34,6 +45,7 @@ export const characters = queryGeneric({ args: {}, returns: v.union(v.null(), v.array(safeCharacterValidator)), handler: async (ctx) => { + if (!deploymentCapabilityEnabled('subscriptions')) return null; const identity = await ctx.auth.getUserIdentity(); if (!identity) return null; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 269d528..f2c5e25 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,12 +1,15 @@ import type { Handle } from '@sveltejs/kit'; import { configuration } from './lib/server/config'; +// Validate deployment settings while the server module initializes, rather +// than waiting for an arbitrary request to discover a typo in the allowlist. +const startupConfig = configuration(); 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); + response.headers.set('x-application-release', startupConfig.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 47d6546..ba13da9 100644 --- a/src/lib/server/application.ts +++ b/src/lib/server/application.ts @@ -57,9 +57,13 @@ export async function assetReport( capability: Extract = 'asset_valuation', ) { const config = configuration(); - if (!capabilityEnabled(capability, config.capabilityAllowlist)) { - return { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) }; - } + // The current shared report includes reference valuation even for the browse + // page. Do not let an asset_search-only rollout accidentally expose it. + const requiredCapabilities: readonly ReleaseCapability[] = capability === 'asset_search' + ? ['asset_search', 'asset_valuation'] + : ['asset_valuation']; + const disabled = requiredCapabilities.find((required) => !capabilityEnabled(required, config.capabilityAllowlist)); + if (disabled) return { status: 'unavailable' as const, reason: capabilityDisabledReason(disabled) }; let reference: SqliteReferenceRepository | undefined; const getReference = () => { const config = configuration(); diff --git a/src/lib/server/release-gate.ts b/src/lib/server/release-gate.ts index 1ffd992..9d5fc93 100644 --- a/src/lib/server/release-gate.ts +++ b/src/lib/server/release-gate.ts @@ -14,6 +14,7 @@ export const RELEASE_CAPABILITIES = [ 'asset_sync', 'assistant_dispatch', 'session_token', + 'identity', 'subscriptions', 'wallet_balance', 'inventory_export', @@ -29,6 +30,8 @@ export const BASELINE_RELEASE_CAPABILITIES: readonly ReleaseCapability[] = [ 'asset_sync', 'assistant_dispatch', 'session_token', + 'identity', + 'subscriptions', ]; const known = new Set(RELEASE_CAPABILITIES); diff --git a/src/routes/auth/eve/+server.ts b/src/routes/auth/eve/+server.ts index a7cfe9c..aec02de 100644 --- a/src/routes/auth/eve/+server.ts +++ b/src/routes/auth/eve/+server.ts @@ -4,10 +4,12 @@ import { configuration } from '$lib/server/config'; import { live, session, SESSION_COOKIE } from '$lib/server/application'; import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security'; import { protectMutation } from '$lib/server/http'; +import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate'; export const POST: RequestHandler = async (event) => { const config = configuration(); protectMutation(event, `oauth:${event.getClientAddress()}`, 10); + if (!capabilityEnabled('identity', config.capabilityAllowlist)) error(503, capabilityDisabledReason('identity')); if (config.mode === 'demo') error(400, 'EVE authentication is disabled in fixture mode'); let destination: string; try { diff --git a/src/routes/auth/eve/callback/+server.ts b/src/routes/auth/eve/callback/+server.ts index 19a07d9..b06d219 100644 --- a/src/routes/auth/eve/callback/+server.ts +++ b/src/routes/auth/eve/callback/+server.ts @@ -3,8 +3,12 @@ import type { RequestHandler } from './$types'; import { live, session, SESSION_COOKIE } from '$lib/server/application'; import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server/security'; import { oauthFailureDiagnostic, type OAuthPhase } from '$lib/server/oauth-diagnostics'; +import { configuration } from '$lib/server/config'; +import { capabilityEnabled } from '$lib/server/release-gate'; export const GET: RequestHandler = async (event) => { + const config = configuration(); + if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled'); const binding = event.cookies.get('eve_oauth_binding'); event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' }); let phase: OAuthPhase = 'state_input'; @@ -14,7 +18,7 @@ export const GET: RequestHandler = async (event) => { if (state.length > 200) throw new Error('Invalid OAuth state'); if (!binding) throw new Error('Missing OAuth binding cookie'); phase = 'configuration'; - const { config, store, cipher, sso } = live(); + const { store, cipher, sso } = live(); const stateHash = hashSecret(state); phase = 'state_consumption'; const pending = await store.consumeOAuth(stateHash, hashSecret(binding)); diff --git a/src/routes/characters/+page.server.ts b/src/routes/characters/+page.server.ts index 41e5557..09acfc6 100644 --- a/src/routes/characters/+page.server.ts +++ b/src/routes/characters/+page.server.ts @@ -1,6 +1,18 @@ import type { PageServerLoad } from './$types'; import { charactersFor, session } from '$lib/server/application'; -export const load: PageServerLoad = async (event) => ({ - characters: await charactersFor((await session(event))?.userId ?? null), - error: event.url.searchParams.get('auth') === 'failed' ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' : null -}); +import { configuration } from '$lib/server/config'; +import { capabilityEnabled, capabilityDisabledReason } from '$lib/server/release-gate'; +export const load: PageServerLoad = async (event) => { + const config = configuration(); + const identityEnabled = capabilityEnabled('identity', config.capabilityAllowlist); + const current = await session(event); + return { + characters: identityEnabled ? await charactersFor(current?.userId ?? null) : [], + identityEnabled, + error: !identityEnabled + ? capabilityDisabledReason('identity') + : event.url.searchParams.get('auth') === 'failed' + ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' + : null, + }; +}; diff --git a/src/routes/characters/+page.svelte b/src/routes/characters/+page.svelte index 21385ff..5740a18 100644 --- a/src/routes/characters/+page.svelte +++ b/src/routes/characters/+page.svelte @@ -4,7 +4,7 @@

APPLICATION-USER AGGREGATION

Characters

Connect each character explicitly. ESI does not expose all characters on an EVE account.

-{#if data.mode !== 'demo'}
{/if} +{#if data.mode !== 'demo' && data.identityEnabled}
{/if} {#if data.error}{/if} {#if data.userId && data.convexUrl && data.mode !== 'demo'}{/if} {#each data.characters as character (character.id)} diff --git a/tests/convex.test.ts b/tests/convex.test.ts index d8d4470..22fac18 100644 --- a/tests/convex.test.ts +++ b/tests/convex.test.ts @@ -5,6 +5,7 @@ import schema from "../src/convex/schema"; import type { Asset } from "../src/lib/domain/model"; process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret"; +process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions"; const modules = { "src/convex/functions.ts": () => import("../src/convex/functions"), @@ -47,6 +48,7 @@ async function login(t: ReturnType, input: Partial = {}) describe("Convex persistence", () => { beforeEach(() => { process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret"; + process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions"; }); test("rejects unauthenticated bridge calls and keeps tenants separate", async () => { @@ -445,4 +447,28 @@ describe("Convex persistence", () => { await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "session-hash" }); await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); }); + + test("backend rollout gate blocks status subscriptions and sync mutations", async () => { + const previous = process.env.APP_CAPABILITY_ALLOWLIST; + process.env.APP_CAPABILITY_ALLOWLIST = ""; + try { + process.env.APP_AUTH_ISSUER = "https://auth.example.test"; + const t = convexTest({ schema, modules }); + const loggedIn = await login(t, { sessionHash: "gated-session" }); + const browser = t.withIdentity({ + subject: loggedIn.userId, + issuer: "https://auth.example.test", + sid: loggedIn.sessionId, + } as any); + await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); + await expect(t.mutation(mutation("bridgeBeginSync"), { + ...bridge, + userId: loggedIn.userId, + characterId: 100, + })).rejects.toThrow("asset_sync"); + } finally { + if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; + else process.env.APP_CAPABILITY_ALLOWLIST = previous; + } + }); }); diff --git a/tests/release-safety.test.ts b/tests/release-safety.test.ts index b5b3839..d809fb8 100644 --- a/tests/release-safety.test.ts +++ b/tests/release-safety.test.ts @@ -13,11 +13,12 @@ describe('deployment rollout gate', () => { 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); + expect(capabilityEnabled('subscriptions', baseline)).toBe(true); }); 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('subscriptions')).toEqual(['subscriptions']); expect(parseCapabilityAllowlist('')).toEqual([]); expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability'); expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability');