fix: enforce rollout gates across backend entry points
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
@@ -57,9 +57,13 @@ export async function assetReport(
|
||||
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) };
|
||||
}
|
||||
// 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();
|
||||
|
||||
@@ -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<string>(RELEASE_CAPABILITIES);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
<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>
|
||||
{#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.userId && data.convexUrl && data.mode !== 'demo'}<LiveCharacters />{/if}
|
||||
{#each data.characters as character (character.id)}
|
||||
|
||||
Reference in New Issue
Block a user