import type { RequestEvent } from '@sveltejs/kit'; import type { Asset, Character, CharacterAssetRepository, MarketEstimates, ReferenceRepository } from '$lib/domain/model'; import { AssetService } from '$lib/domain/assets'; import { configuration, requireLiveConfig } from './config'; import { ConvexStore } from './store'; 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 }; let liveServices: ReturnType | undefined; function createLive() { const config = configuration(); requireLiveConfig(config); const store = new ConvexStore(config.convexUrl, config.bridgeSecret); const cipher = new TokenCipher(config.encryptionKey); const sso = new EveSso({ clientId: config.eveClientId, clientSecret: config.eveClientSecret || undefined, redirectUri: config.eveCallback, userAgent: config.userAgent }); const esi = new EsiClient({ userAgent: config.userAgent }); return { config, store, cipher, sso, esi, markets: new EsiMarketEstimates(esi) }; } export const live = () => liveServices ??= createLive(); export async function session(event: RequestEvent): Promise { const config = configuration(); if (config.mode === 'demo') return { userId: 'demo', sessionId: 'demo' }; const cookie = event.cookies.get(SESSION_COOKIE); if (!cookie || !config.convexUrl || !config.bridgeSecret) return null; return new ConvexStore(config.convexUrl, config.bridgeSecret).session(hashSecret(cookie)); } function demoRepository(): CharacterAssetRepository { const now = Date.now(); const characters: Character[] = [90000001, 90000002].map((id, index) => ({ id, userId: 'demo', name: `Fixture Pilot ${index + 1}`, scopes: ['esi-assets.read_assets.v1'], assets: { status: 'ready', syncedAt: now, expiresAt: now + 3_600_000 } })); const asset = (characterId: number, itemId: string, typeId: number, quantity: number, extra = {}): Asset => ({ characterId, itemId, typeId, quantity, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar', ...extra }); const items = [asset(90000001, '1000000000001', 34, 25000), asset(90000001, '1000000000002', 587, 3), asset(90000002, '1000000000003', 34, 5000), asset(90000002, '1000000000004', 35, 2000), asset(90000002, '1000000000005', 687, 1, { isBlueprintCopy: true }), asset(90000002, '1000000000006', 999999, 1)]; return { async characters(userId) { return userId === 'demo' ? characters : []; }, async assets(userId, characterId) { return userId === 'demo' ? items.filter(a => a.characterId === characterId) : []; } }; } function repository(): CharacterAssetRepository { if (configuration().mode === 'demo') return demoRepository(); return { characters: userId => live().store.characters(userId), assets: (userId, id) => live().store.assets(userId, id) }; } export async function charactersFor(userId: string | null): Promise { 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, capability: Extract = 'asset_valuation', ) { const config = configuration(); // 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(); // Live character data must never be valued against a silently seeded fixture catalog. return reference ??= new SqliteReferenceRepository(config.referenceDir, config.mode === 'live' ? 'production' : undefined); }; const ref: ReferenceRepository = { types: ids => getReference().types(ids), version: () => getReference().version() }; const market: MarketEstimates = configuration().mode === 'demo' ? { async estimates() { const now = Date.now(); return { prices: [{ typeId: 34, unitPrice: '400' }, { typeId: 35, unitPrice: '800' }, { typeId: 587, unitPrice: '65000000' }], source: 'Synthetic fixture prices — NOT ESI', asOf: now, expiresAt: now + 3_600_000 }; } } : { estimates: ids => live().markets.estimates(ids) }; try { return await new AssetService(repository(), ref, market).report(userId); } catch { return { status: 'unavailable' as const, reason: 'Asset report unavailable. Check reference import, backend configuration, and server diagnostics.' }; } finally { reference?.close(); } } const refreshes = new Map>(); async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise { const key = `${userId}:${characterId}`; const existing = refreshes.get(key); if (existing) return existing; const promise = (async () => { const { store, cipher, sso } = live(); const record = await store.tokenRecord(userId, characterId); if (!record) throw new Error('Character authorization is missing'); const previous = await cipher.open(record.encryptedTokens, `character:${characterId}`); if (previous.characterId !== characterId) throw new Error('Character authorization mismatch'); if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous; const grant = await sso.refresh(previous.refreshToken); if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) throw new Error('Character ownership changed; reconnect required'); const saved = await store.saveTokens(userId, characterId, { encryptedTokens: await cipher.seal(grant, `character:${characterId}`), tokenExpiresAt: grant.expiresAt, scopes: grant.scopes, expectedVersion: record.version }); if (!saved) throw new Error('Authorization changed concurrently; retry synchronization'); return grant; })(); refreshes.set(key, promise); try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); } } export async function synchronize(userId: string) { 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; for (const character of characters) { if (!character.scopes.includes('esi-assets.read_assets.v1')) { failed.push(character.id); continue; } let generation: string | undefined; try { generation = await store.beginSync(userId, character.id); // Explicit synchronization revalidates revocation/owner continuity even when ESI assets remain cached. const grant = await characterGrant(userId, character.id, true); if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing'); const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`); const chunks = Math.ceil(snapshot.assets.length / 250); for (let index = 0; index < chunks; index++) await store.stageAssets(userId, character.id, generation, index, snapshot.assets.slice(index * 250, (index + 1) * 250)); await store.finishSync(userId, character.id, generation, chunks, snapshot.syncedAt, snapshot.expiresAt); completed++; } catch { failed.push(character.id); if (generation) await store.failSync(userId, character.id, generation, 'Synchronization failed. Reconnect if authorization was revoked, or retry after ESI recovers.').catch(() => {}); } } return { completed, failed, demo: false }; }