Files
eden/src/lib/domain/assets.ts
T

402 lines
14 KiB
TypeScript

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<number, string>;
metadata: Omit<PriceSnapshot, 'prices'>;
stale: boolean;
};
function validateMarketSnapshot(snapshot: unknown, now: number): ValidatedMarketSnapshot | 'malformed' {
if (snapshot === null || typeof snapshot !== 'object') return 'malformed';
const candidate = snapshot as Partial<PriceSnapshot> & { 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<number, string>();
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<AssetOperation> {
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<number>();
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<string>();
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<number, InventoryType>();
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,
},
};
}
}