Initial Commit
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
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 (!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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user