134 lines
8.4 KiB
TypeScript
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 };
|
|
}
|