402 lines
14 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|
|
}
|