Files
eden/src/lib/server/application.ts
T

134 lines
8.4 KiB
TypeScript

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<typeof createLive> | 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<Session | null> {
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<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,
capability: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'> = '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<string, Promise<VerifiedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<VerifiedGrant> {
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<VerifiedGrant>(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 };
}