fix: enforce rollout gates across backend entry points
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_MODE=live
|
|||||||
# Immutable image/application identifier in staging/production; local is for development only.
|
# Immutable image/application identifier in staging/production; local is for development only.
|
||||||
APP_RELEASE_ID=local
|
APP_RELEASE_ID=local
|
||||||
# Comma-separated server-side rollout allowlist. New capabilities are off by default.
|
# 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
|
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
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_MODE: ${APP_MODE:-live}
|
APP_MODE: ${APP_MODE:-live}
|
||||||
APP_RELEASE_ID: ${APP_RELEASE_ID:-local}
|
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}
|
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
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# 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_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
|
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
|
||||||
|
|||||||
+14
-6
@@ -11,25 +11,33 @@ write a persistent Convex backend.
|
|||||||
`APP_CAPABILITY_ALLOWLIST` is a comma-separated deployment setting parsed by
|
`APP_CAPABILITY_ALLOWLIST` is a comma-separated deployment setting parsed by
|
||||||
`src/lib/server/release-gate.ts`. An omitted setting preserves the existing
|
`src/lib/server/release-gate.ts`. An omitted setting preserves the existing
|
||||||
private asset/sign-in baseline (`asset_search`, `asset_valuation`,
|
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
|
setting disables all capabilities. Unknown names fail configuration rather
|
||||||
than becoming an accidental opt-in; known capabilities that are not in the
|
than becoming an accidental opt-in; known capabilities that are not in the
|
||||||
baseline (for example `wallet_balance`, `inventory_export`, and
|
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:
|
The web server checks the allowlist at the relevant boundary:
|
||||||
|
|
||||||
- `/api/chat` checks `assistant_dispatch` before invoking manual or external
|
- `/api/chat` checks `assistant_dispatch` before invoking manual or external
|
||||||
intent routing.
|
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/sync` and the application synchronizer check `asset_sync`.
|
||||||
- `/api/session/token` checks `session_token`.
|
- `/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
|
This is an admission control mechanism, not a UI feature flag. Disabled
|
||||||
operations return an explicit unavailable/503 response and do not invoke a
|
operations return an explicit unavailable/503 response and do not invoke a
|
||||||
provider. No subscription or durable user-job service exists in this baseline;
|
provider. The current character status subscription is gated in Convex; no
|
||||||
their capability names remain closed until those services have a reviewed
|
durable user-job service exists in this baseline, so future job kinds remain
|
||||||
entry point.
|
closed until they have reviewed enqueue and worker entry points.
|
||||||
|
|
||||||
## Immutable release metadata
|
## Immutable release metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export {};
|
export {};
|
||||||
// Run only against your intended self-hosted deployment. Values are never printed.
|
// 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.');
|
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];
|
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.
|
// 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' });
|
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).`);
|
if (await child.exited !== 0) throw new Error(`Could not configure ${name}; check deployment URL/admin key (output suppressed to protect secrets).`);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { assetValidator } from "./schema";
|
import { assetValidator } from "./schema";
|
||||||
|
import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate";
|
||||||
|
|
||||||
const MAX_ASSET_CHUNK = 250;
|
const MAX_ASSET_CHUNK = 250;
|
||||||
const SYNC_LEASE_MS = 5 * 60 * 1000;
|
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 {
|
function constantTimeEqual(a: string, b: string): boolean {
|
||||||
let difference = a.length ^ b.length;
|
let difference = a.length ^ b.length;
|
||||||
const length = Math.max(a.length, b.length);
|
const length = Math.max(a.length, b.length);
|
||||||
@@ -412,6 +423,7 @@ export const bridgeBeginSync = mutationGeneric({
|
|||||||
args: { ...bridgeArgs, ...characterArgs },
|
args: { ...bridgeArgs, ...characterArgs },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
|
assertDeploymentCapability('asset_sync');
|
||||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||||
if (!character) throw new Error("Character not found");
|
if (!character) throw new Error("Character not found");
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
@@ -446,6 +458,7 @@ export const bridgeStageAssets = mutationGeneric({
|
|||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
|
assertDeploymentCapability('asset_sync');
|
||||||
if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) {
|
if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) {
|
||||||
throw new Error("Invalid asset chunk");
|
throw new Error("Invalid asset chunk");
|
||||||
}
|
}
|
||||||
@@ -511,6 +524,7 @@ export const bridgeFinishSync = mutationGeneric({
|
|||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
|
assertDeploymentCapability('asset_sync');
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(args.syncedAt) ||
|
!Number.isFinite(args.syncedAt) ||
|
||||||
@@ -574,6 +588,7 @@ export const bridgeFailSync = mutationGeneric({
|
|||||||
args: { ...bridgeArgs, ...characterArgs, generation: v.string(), error: v.string() },
|
args: { ...bridgeArgs, ...characterArgs, generation: v.string(), error: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
|
assertDeploymentCapability('asset_sync');
|
||||||
const character = await findCharacter(ctx, args.userId, args.characterId);
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
||||||
if (!character || character.syncGeneration !== args.generation) {
|
if (!character || character.syncGeneration !== args.generation) {
|
||||||
throw new Error("Synchronization generation is not current");
|
throw new Error("Synchronization generation is not current");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { queryGeneric } from "convex/server";
|
import { queryGeneric } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { characterStateValidator } from "./schema";
|
import { characterStateValidator } from "./schema";
|
||||||
|
import { capabilityEnabled, parseCapabilityAllowlist } from "../lib/server/release-gate";
|
||||||
|
|
||||||
const safeAssetSyncValidator = v.object({
|
const safeAssetSyncValidator = v.object({
|
||||||
status: characterStateValidator,
|
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
|
* Browser-facing character status. Convex validates RS256 issuer/audience
|
||||||
* through auth.config.ts; the checks here make the application boundary and
|
* through auth.config.ts; the checks here make the application boundary and
|
||||||
@@ -34,6 +45,7 @@ export const characters = queryGeneric({
|
|||||||
args: {},
|
args: {},
|
||||||
returns: v.union(v.null(), v.array(safeCharacterValidator)),
|
returns: v.union(v.null(), v.array(safeCharacterValidator)),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
|
if (!deploymentCapabilityEnabled('subscriptions')) return null;
|
||||||
const identity = await ctx.auth.getUserIdentity();
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
if (!identity) return null;
|
if (!identity) return null;
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,12 +1,15 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { configuration } from './lib/server/config';
|
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 }) => {
|
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
|
// The identifier is immutable build metadata, never a secret or an auth
|
||||||
// decision. It makes rollback/operator diagnostics auditable.
|
// 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
|
// 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)
|
||||||
|
|||||||
@@ -57,9 +57,13 @@ export async function assetReport(
|
|||||||
capability: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'> = 'asset_valuation',
|
capability: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'> = 'asset_valuation',
|
||||||
) {
|
) {
|
||||||
const config = configuration();
|
const config = configuration();
|
||||||
if (!capabilityEnabled(capability, config.capabilityAllowlist)) {
|
// The current shared report includes reference valuation even for the browse
|
||||||
return { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) };
|
// 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;
|
let reference: SqliteReferenceRepository | undefined;
|
||||||
const getReference = () => {
|
const getReference = () => {
|
||||||
const config = configuration();
|
const config = configuration();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const RELEASE_CAPABILITIES = [
|
|||||||
'asset_sync',
|
'asset_sync',
|
||||||
'assistant_dispatch',
|
'assistant_dispatch',
|
||||||
'session_token',
|
'session_token',
|
||||||
|
'identity',
|
||||||
'subscriptions',
|
'subscriptions',
|
||||||
'wallet_balance',
|
'wallet_balance',
|
||||||
'inventory_export',
|
'inventory_export',
|
||||||
@@ -29,6 +30,8 @@ export const BASELINE_RELEASE_CAPABILITIES: readonly ReleaseCapability[] = [
|
|||||||
'asset_sync',
|
'asset_sync',
|
||||||
'assistant_dispatch',
|
'assistant_dispatch',
|
||||||
'session_token',
|
'session_token',
|
||||||
|
'identity',
|
||||||
|
'subscriptions',
|
||||||
];
|
];
|
||||||
|
|
||||||
const known = new Set<string>(RELEASE_CAPABILITIES);
|
const known = new Set<string>(RELEASE_CAPABILITIES);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { configuration } from '$lib/server/config';
|
|||||||
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
||||||
import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security';
|
import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security';
|
||||||
import { protectMutation } from '$lib/server/http';
|
import { protectMutation } from '$lib/server/http';
|
||||||
|
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const config = configuration();
|
const config = configuration();
|
||||||
protectMutation(event, `oauth:${event.getClientAddress()}`, 10);
|
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');
|
if (config.mode === 'demo') error(400, 'EVE authentication is disabled in fixture mode');
|
||||||
let destination: string;
|
let destination: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
|
|||||||
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
import { live, session, SESSION_COOKIE } from '$lib/server/application';
|
||||||
import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server/security';
|
import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server/security';
|
||||||
import { oauthFailureDiagnostic, type OAuthPhase } from '$lib/server/oauth-diagnostics';
|
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) => {
|
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');
|
const binding = event.cookies.get('eve_oauth_binding');
|
||||||
event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' });
|
event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' });
|
||||||
let phase: OAuthPhase = 'state_input';
|
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 (state.length > 200) throw new Error('Invalid OAuth state');
|
||||||
if (!binding) throw new Error('Missing OAuth binding cookie');
|
if (!binding) throw new Error('Missing OAuth binding cookie');
|
||||||
phase = 'configuration';
|
phase = 'configuration';
|
||||||
const { config, store, cipher, sso } = live();
|
const { store, cipher, sso } = live();
|
||||||
const stateHash = hashSecret(state);
|
const stateHash = hashSecret(state);
|
||||||
phase = 'state_consumption';
|
phase = 'state_consumption';
|
||||||
const pending = await store.consumeOAuth(stateHash, hashSecret(binding));
|
const pending = await store.consumeOAuth(stateHash, hashSecret(binding));
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { charactersFor, session } from '$lib/server/application';
|
import { charactersFor, session } from '$lib/server/application';
|
||||||
export const load: PageServerLoad = async (event) => ({
|
import { configuration } from '$lib/server/config';
|
||||||
characters: await charactersFor((await session(event))?.userId ?? null),
|
import { capabilityEnabled, capabilityDisabledReason } from '$lib/server/release-gate';
|
||||||
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
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
|
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
|
||||||
<p class="muted">Connect each character explicitly. ESI does not expose all characters on an EVE account.</p>
|
<p class="muted">Connect each character explicitly. ESI does not expose all characters on an EVE account.</p>
|
||||||
{#if data.mode !== 'demo'}<form method="POST" action="/auth/eve"><button>Connect EVE Character</button></form>{/if}
|
{#if data.mode !== 'demo' && data.identityEnabled}<form method="POST" action="/auth/eve"><button>Connect EVE Character</button></form>{/if}
|
||||||
{#if data.error}<p class="error" role="alert">{data.error}</p>{/if}
|
{#if data.error}<p class="error" role="alert">{data.error}</p>{/if}
|
||||||
{#if data.userId && data.convexUrl && data.mode !== 'demo'}<LiveCharacters />{/if}
|
{#if data.userId && data.convexUrl && data.mode !== 'demo'}<LiveCharacters />{/if}
|
||||||
{#each data.characters as character (character.id)}
|
{#each data.characters as character (character.id)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import schema from "../src/convex/schema";
|
|||||||
import type { Asset } from "../src/lib/domain/model";
|
import type { Asset } from "../src/lib/domain/model";
|
||||||
|
|
||||||
process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret";
|
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 = {
|
const modules = {
|
||||||
"src/convex/functions.ts": () => import("../src/convex/functions"),
|
"src/convex/functions.ts": () => import("../src/convex/functions"),
|
||||||
@@ -47,6 +48,7 @@ async function login(t: ReturnType<typeof convexTest>, input: Partial<any> = {})
|
|||||||
describe("Convex persistence", () => {
|
describe("Convex persistence", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret";
|
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 () => {
|
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 t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "session-hash" });
|
||||||
await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull();
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ describe('deployment rollout gate', () => {
|
|||||||
expect(baseline).toEqual(BASELINE_RELEASE_CAPABILITIES);
|
expect(baseline).toEqual(BASELINE_RELEASE_CAPABILITIES);
|
||||||
expect(capabilityEnabled('asset_search', baseline)).toBe(true);
|
expect(capabilityEnabled('asset_search', baseline)).toBe(true);
|
||||||
expect(capabilityEnabled('wallet_balance', baseline)).toBe(false);
|
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', () => {
|
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('asset_search, asset_search,session_token')).toEqual(['asset_search', 'session_token']);
|
||||||
|
expect(parseCapabilityAllowlist('subscriptions')).toEqual(['subscriptions']);
|
||||||
expect(parseCapabilityAllowlist('')).toEqual([]);
|
expect(parseCapabilityAllowlist('')).toEqual([]);
|
||||||
expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability');
|
expect(() => parseCapabilityAllowlist('asset_search,typo')).toThrow('Unknown deployment capability');
|
||||||
expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability');
|
expect(() => parseCapabilityAllowlist('asset_search,')).toThrow('Unknown deployment capability');
|
||||||
|
|||||||
Reference in New Issue
Block a user