import type { Asset, AssetOperation, Character, CharacterAssetRepository, InventoryType, MarketEstimates, MissingRequirement, PriceSnapshot, ReferenceRepository, UserId, ValuedAsset, } from './model'; import { ASSETS_SCOPE, CONNECT_ACTION, SYNCHRONIZE_ACTION, authenticationRequired, hasExactScope, requirementsMissing, } from './capabilities'; import { multiplyIsk, numberToIsk, parseIsk, toIsk, validateQuantity } from './money'; export { ASSETS_SCOPE, ESI_ASSETS_SCOPE, REQUIRED_ASSETS_SCOPE } from './capabilities'; /** A clock is injectable so freshness decisions are deterministic in tests and callers. */ export type Clock = (() => number) | { now: () => number } | { now: number } | number; export type AssetServiceOptions = { repository: CharacterAssetRepository; reference: ReferenceRepository; markets: MarketEstimates; clock?: Clock; }; const REFERENCE_WARNING = 'Reference valuation is an estimate, not a Jita price, firm sale offer, immediate liquidation value, or liquidity assertion.'; function unavailable(reason: string): AssetOperation { return { status: 'unavailable', reason }; } function readClock(clock: Clock): number { const value = typeof clock === 'number' ? clock : typeof clock === 'function' ? clock() : typeof clock.now === 'function' ? clock.now() : clock.now; if (typeof value !== 'number' || !Number.isFinite(value)) { throw new RangeError('Clock must return a finite timestamp'); } return value; } function characterIdIsValid(character: Character): boolean { return Number.isSafeInteger(character.id) && character.id >= 0; } function characterFresh(character: Character, now: number): boolean { return ( character.assets.status === 'ready' && typeof character.assets.syncedAt === 'number' && Number.isFinite(character.assets.syncedAt) && character.assets.syncedAt <= now && typeof character.assets.expiresAt === 'number' && Number.isFinite(character.assets.expiresAt) && now < character.assets.expiresAt ); } function missingForCharacter(character: Character, now: number): MissingRequirement[] { const missing: MissingRequirement[] = []; if (!hasExactScope(character.scopes, ASSETS_SCOPE)) { missing.push({ requirement: 'assets', characterId: character.id, reason: 'authorization' }); } if (character.assets.status !== 'ready') { missing.push({ requirement: 'assets', characterId: character.id, reason: 'synchronization' }); } else if (!characterFresh(character, now)) { missing.push({ requirement: 'assets', characterId: character.id, reason: 'stale' }); } return missing; } function actionSet(missing: readonly MissingRequirement[]) { const actions = [] as typeof CONNECT_ACTION[]; if (missing.some((entry) => entry.reason === 'authorization')) actions.push({ ...CONNECT_ACTION }); if (missing.some((entry) => entry.reason === 'synchronization' || entry.reason === 'stale')) { actions.push({ ...SYNCHRONIZE_ACTION }); } return actions; } function isFiniteSafeTypeId(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; } function validateAsset(asset: Asset, characterId: number): string | null { if (!asset || typeof asset.itemId !== 'string' || !/^[1-9]\d*$/.test(asset.itemId)) return 'Asset item ID is invalid'; if (asset.characterId !== characterId) return 'Asset ownership validation failed'; if (asset.isSingleton !== undefined && typeof asset.isSingleton !== 'boolean') return 'Asset singleton flag is invalid'; if (!isFiniteSafeTypeId(asset.typeId)) return 'Asset type ID is invalid'; try { validateQuantity(asset.quantity); } catch { return 'Asset quantity must be a non-negative safe integer'; } return null; } function validateMarketPrice(value: unknown): string | null { try { if (typeof value === 'number') { if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) return null; return numberToIsk(value); } if (typeof value === 'bigint') { if (value < 0n) return null; return value.toString(10); } if (typeof value === 'string') { const parsed = parseIsk(value); if (parsed < 0n) return null; return parsed.toString(10); } } catch { return null; } return null; } type ValidatedMarketSnapshot = { prices: Map; metadata: Omit; stale: boolean; }; function validateMarketSnapshot(snapshot: unknown, now: number): ValidatedMarketSnapshot | 'malformed' { if (snapshot === null || typeof snapshot !== 'object') return 'malformed'; const candidate = snapshot as Partial & { prices?: unknown }; if ( typeof candidate.source !== 'string' || candidate.source.length === 0 || typeof candidate.asOf !== 'number' || !Number.isFinite(candidate.asOf) || typeof candidate.expiresAt !== 'number' || !Number.isFinite(candidate.expiresAt) || !Array.isArray(candidate.prices) ) { return 'malformed'; } // The source may stamp a snapshot while this report is in flight. It is // malformed only when its own interval is impossible; freshness is decided // from expiresAt below. if (candidate.expiresAt < candidate.asOf) return 'malformed'; const prices = new Map(); for (const estimate of candidate.prices) { if (estimate === null || typeof estimate !== 'object') return 'malformed'; const value = estimate as { typeId?: unknown; unitPrice?: unknown }; if (!isFiniteSafeTypeId(value.typeId) || prices.has(value.typeId)) return 'malformed'; const unitPrice = validateMarketPrice(value.unitPrice); // This deliberately rejects NaN, negative and non-integer prices. A bad // estimate cannot be silently treated like a missing quote or zero. if (unitPrice === null) return 'malformed'; prices.set(value.typeId, unitPrice); } return { prices, metadata: { source: candidate.source, asOf: candidate.asOf, expiresAt: candidate.expiresAt, }, stale: now >= candidate.expiresAt, }; } function sortCharacters(characters: readonly Character[]): Character[] { return [...characters].sort((left, right) => left.id - right.id); } function sortAssets(assets: readonly Asset[]): Asset[] { return [...assets].sort((left, right) => { const character = left.characterId - right.characterId; if (character !== 0) return character; const item = left.itemId < right.itemId ? -1 : left.itemId > right.itemId ? 1 : 0; if (item !== 0) return item; return left.typeId - right.typeId; }); } /** * Shared deterministic asset browsing and reference valuation service. The * browser and assistant should both call this service rather than reimplement * ownership, synchronization, or valuation rules. */ export class AssetService { private readonly repository: CharacterAssetRepository; private readonly reference: ReferenceRepository; private readonly markets: MarketEstimates; private readonly clock: Clock; constructor(repository: CharacterAssetRepository, reference: ReferenceRepository, markets: MarketEstimates, clock?: Clock); constructor(options: AssetServiceOptions); constructor( repositoryOrOptions: CharacterAssetRepository | AssetServiceOptions, reference?: ReferenceRepository, markets?: MarketEstimates, clock: Clock = () => Date.now(), ) { if ('repository' in repositoryOrOptions && reference === undefined) { this.repository = repositoryOrOptions.repository; this.reference = repositoryOrOptions.reference; this.markets = repositoryOrOptions.markets; this.clock = repositoryOrOptions.clock ?? (() => Date.now()); } else { if (reference === undefined || markets === undefined) { throw new TypeError('AssetService requires repository, reference and markets'); } this.repository = repositoryOrOptions as CharacterAssetRepository; this.reference = reference; this.markets = markets; this.clock = clock; } } async report(userId: UserId | null): Promise { if (typeof userId !== 'string' || userId.length === 0) { return authenticationRequired('asset_valuation'); } let now: number; try { now = readClock(this.clock); } catch { return unavailable('Clock returned an invalid timestamp'); } let characters: Character[]; try { const loaded = await this.repository.characters(userId); if (!Array.isArray(loaded)) return unavailable('Character repository returned invalid data'); characters = sortCharacters(loaded); } catch { return unavailable('Unable to load connected characters'); } if (characters.length === 0) { return requirementsMissing('asset_valuation', [{ requirement: 'assets', reason: 'authorization' }], [{ ...CONNECT_ACTION }]); } const seenCharacters = new Set(); for (const character of characters) { if ( !character || character.userId !== userId || !characterIdIsValid(character) || seenCharacters.has(character.id) || !character.assets || !Array.isArray(character.scopes) ) { return unavailable('Character ownership validation failed'); } seenCharacters.add(character.id); } // Evaluate every connected character before reading any assets. Returning // a report for the ready subset would falsely imply complete coverage. const missing = characters.flatMap((character) => missingForCharacter(character, now)); if (missing.length > 0) { return requirementsMissing('asset_valuation', missing, actionSet(missing)); } const assets: Asset[] = []; const seenAssets = new Set(); try { for (const character of characters) { const loaded = await this.repository.assets(userId, character.id); if (!Array.isArray(loaded)) return unavailable('Asset repository returned invalid data'); for (const asset of loaded) { const reason = validateAsset(asset, character.id); if (reason !== null) return unavailable(reason); const key = `${character.id}:${asset.itemId}`; if (seenAssets.has(key)) return unavailable('Duplicate asset item ID in character snapshot'); seenAssets.add(key); assets.push(asset); } } } catch { return unavailable('Unable to load character assets'); } const orderedAssets = sortAssets(assets); const typeIds = [...new Set(orderedAssets.map((asset) => asset.typeId))].sort((a, b) => a - b); let referenceVersion: string; let referenceTypes: InventoryType[]; try { referenceVersion = await this.reference.version(); referenceTypes = typeIds.length === 0 ? [] : await this.reference.types(typeIds); if (typeof referenceVersion !== 'string' || !Array.isArray(referenceTypes)) { return unavailable('Reference repository returned invalid data'); } } catch { return unavailable('Unable to load reference inventory data'); } const types = new Map(); for (const type of referenceTypes) { if (!type || !isFiniteSafeTypeId(type.id) || types.has(type.id) || typeof type.name !== 'string') { return unavailable('Reference repository returned malformed inventory data'); } types.set(type.id, type); } // Blueprint copies and unknown types are never sent to the market source: // neither can safely acquire a normal sell valuation. const pricedTypeIds = [ ...new Set(orderedAssets.filter((asset) => !asset.isBlueprintCopy && types.has(asset.typeId)).map((asset) => asset.typeId)), ].sort((a, b) => a - b); let snapshot: PriceSnapshot; try { snapshot = await this.markets.estimates(pricedTypeIds); } catch { return unavailable('Unable to load market estimates'); } const validated = validateMarketSnapshot(snapshot, now); if (validated === 'malformed') return unavailable('Market estimates contain malformed prices'); if (validated.stale) { return requirementsMissing( 'asset_valuation', [{ requirement: 'market_data', reason: 'stale' }], [{ ...SYNCHRONIZE_ACTION }], ); } let total = '0'; let unpricedItems = 0; const valuedItems: ValuedAsset[] = []; for (const asset of orderedAssets) { const type = types.get(asset.typeId); let unitPrice: string | null = null; let estimatedValue: string | null = null; let exclusion: ValuedAsset['exclusion']; if (asset.isBlueprintCopy === true) { exclusion = 'blueprint_copy'; } else if (!type) { exclusion = 'unknown_type'; } else { unitPrice = validated.prices.get(asset.typeId) ?? null; if (unitPrice === null) exclusion = 'missing_price'; } if (unitPrice === null) { unpricedItems += 1; } else { try { // Re-validate quantity at the arithmetic boundary as a defense in // depth should a repository object be mutated between reads. validateQuantity(asset.quantity); estimatedValue = multiplyIsk(unitPrice, asset.quantity); total = toIsk(BigInt(total) + BigInt(estimatedValue)); } catch { return unavailable('Asset quantity or price failed validation'); } } valuedItems.push({ ...asset, name: type?.name ?? 'Unknown type', unitPrice, estimatedValue, ...(exclusion === undefined ? {} : { exclusion }), }); } const warnings = [REFERENCE_WARNING]; if (unpricedItems > 0) { warnings.push(`${unpricedItems} item${unpricedItems === 1 ? '' : 's'} excluded from the estimate because no safe reference value was available.`); } return { status: 'ready', report: { method: 'reference_estimate', total, items: valuedItems, characters: characters.map((character) => ({ id: character.id, name: character.name, syncedAt: character.assets.syncedAt as number, })), unpricedItems, referenceVersion, prices: validated.metadata, warnings, }, }; } }