import { randomUUID } from 'node:crypto'; import type { CompleteReportProvenance, DatasetKind, InventoryCursor, InventoryCursorLookup, InventoryCursorPin, InventoryCursorState, InventoryFilters, InventoryQueryResult, InventoryRow, InventorySort, Isk, SelectedResource, } from '../../domain/model'; import { ESI_ASSETS_SCOPE, validateInventoryCursorUse, validateInventoryPageResult } from '../../domain/capabilities'; import { compareDecimalIds, isDecimalId, stableSerialize } from '../../domain/inventory-location'; import { parseIsk, validateQuantity } from '../../domain/money'; import { INVENTORY_QUERY_MAX_MATCHING_ROWS, INVENTORY_QUERY_MAX_PAGE_SIZE, type InventoryLocationSelector, type InventoryPricingSelector, type InventoryQueryRepository, type InventoryRepositoryPage, type InventoryRepositoryPageRequest, type InventoryResourceSnapshot, validateInventoryCursorState, type ValidatedInventoryFilters, } from './query-repository'; import { MAX_INVENTORY_MONEY_TEXT_LENGTH, validateInventoryProjectionRow, type InventoryProjectionRow } from './projection'; export const DEFAULT_INVENTORY_CURSOR_TTL_MS = 15 * 60 * 1_000; export const MAX_INVENTORY_CURSOR_TTL_MS = 24 * 60 * 60 * 1_000; export const INVENTORY_QUERY_MAX_FILTER_VALUES = 1_000; export const MAX_INVENTORY_CURSOR_TOKEN_LENGTH = 256; export type InventoryQueryRequest = { callerUserId: string; /** Omit to select all currently linked, server-authorized characters. */ selectedResources?: readonly SelectedResource[]; filters: InventoryFilters & { pricing?: InventoryPricingSelector; location?: InventoryLocationSelector; }; sort?: InventorySort; pageSize?: number; cursor?: InventoryCursor | string | null; }; export type InventoryQueryServiceOptions = { repository: InventoryQueryRepository; now?: () => number; cursorTtlMs?: number; maxPageSize?: number; }; type NormalizedRequest = { callerUserId: string; selectedResources: readonly SelectedResource[]; resourceSelectionExplicit: boolean; filters: ValidatedInventoryFilters; sort: InventorySort; pageSize: number; cursor: InventoryCursor | null; }; type CursorFailure = 'authorization_required' | 'stale' | 'unavailable'; function finiteNow(clock: () => number): number { const value = clock(); if (!Number.isFinite(value)) throw new RangeError('inventory query clock returned an invalid timestamp'); return value; } function validIdList(value: unknown, field: string): string[] { if (value === undefined) return []; if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`); const result = value.map((id, index) => { if (!isDecimalId(id)) throw new TypeError(`${field}[${index}] is invalid`); return id; }); if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`); return [...result].sort(compareDecimalIds); } function validNumberList(value: unknown, field: string): number[] { if (value === undefined) return []; if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`); const result = value.map((id, index) => { if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new TypeError(`${field}[${index}] is invalid`); return id; }); if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`); return [...result].sort((left, right) => left - right); } function validQuantity(value: unknown, field: string): bigint | null { if (value === undefined) return null; if (typeof value !== 'string' || !/^(?:0|[1-9][0-9]*)$(?![\s\S])/.test(value) || value.length > 128) throw new TypeError(`${field} must be a bounded non-negative integer string`); return BigInt(value); } function normalizeFilters(input: unknown): ValidatedInventoryFilters { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('inventory filters are required'); const raw = input as Record; const allowed = new Set(['characterIds', 'typeIds', 'customName', 'categoryIds', 'groupIds', 'minQuantity', 'maxQuantity', 'locationIds', 'systemIds', 'regionIds', 'blueprintState', 'pricing', 'location']); for (const key of Object.keys(raw)) if (!allowed.has(key)) throw new TypeError(`unsupported inventory filter: ${key}`); const typeIds = validNumberList(raw.typeIds, 'typeIds'); const categoryIds = validNumberList(raw.categoryIds, 'categoryIds'); const groupIds = validNumberList(raw.groupIds, 'groupIds'); const systemIds = validNumberList(raw.systemIds, 'systemIds'); const regionIds = validNumberList(raw.regionIds, 'regionIds'); const locationValues = raw.locationIds === undefined ? [] : raw.locationIds; if (!Array.isArray(locationValues) || locationValues.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('locationIds filter cap exceeded'); const locationIds: string[] = []; let location: InventoryLocationSelector | null = raw.location === undefined ? null : raw.location === 'Unresolved' ? 'unresolved' : raw.location as InventoryLocationSelector; for (const [index, value] of locationValues.entries()) { if (value === 'unresolved' || value === 'Unresolved') { if (location !== null && location !== 'unresolved') throw new TypeError('location selectors conflict'); location = 'unresolved'; } else if (isDecimalId(value)) locationIds.push(value); else throw new TypeError(`locationIds[${index}] is invalid`); } if (location !== null && location !== 'unresolved') throw new TypeError('location filter is invalid'); if (location === 'unresolved' && (locationIds.length > 0 || systemIds.length > 0 || regionIds.length > 0)) throw new TypeError('unresolved location filter cannot be combined with named location filters'); if (raw.customName !== undefined && (typeof raw.customName !== 'string' || raw.customName.length > 512)) throw new TypeError('customName filter is invalid'); if (raw.blueprintState !== undefined && !['all', 'original', 'copy', 'unknown'].includes(raw.blueprintState as string)) throw new TypeError('blueprintState filter is invalid'); const pricing = raw.pricing === undefined ? null : raw.pricing as InventoryPricingSelector; if (pricing !== null && pricing !== 'priced' && pricing !== 'unpriced') throw new TypeError('pricing filter is invalid'); const minQuantity = validQuantity(raw.minQuantity, 'minQuantity'); const maxQuantity = validQuantity(raw.maxQuantity, 'maxQuantity'); if (minQuantity !== null && maxQuantity !== null && minQuantity > maxQuantity) throw new RangeError('quantity filter range is invalid'); return { characterIds: validIdList(raw.characterIds, 'characterIds'), typeIds, customName: raw.customName === undefined || raw.customName === '' ? null : raw.customName as string, categoryIds, groupIds, minQuantity, maxQuantity, blueprintState: (raw.blueprintState ?? 'all') as ValidatedInventoryFilters['blueprintState'], locationIds: [...new Set(locationIds)].sort(compareDecimalIds), systemIds, regionIds, pricing, location, }; } function normalizeSort(value: unknown): InventorySort { if (!value || typeof value !== 'object') throw new TypeError('inventory sort is required'); const candidate = value as Partial; if (!['character', 'name', 'quantity', 'location', 'estimated_value'].includes(candidate.field as string)) throw new TypeError('inventory sort field is invalid'); if (candidate.direction !== 'ascending' && candidate.direction !== 'descending') throw new TypeError('inventory sort direction is invalid'); if (candidate.tieBreaker !== 'item_id') throw new TypeError('inventory sort tie-breaker is invalid'); return { field: candidate.field!, direction: candidate.direction, tieBreaker: 'item_id' }; } function normalizeCursor(value: unknown): InventoryCursor | null { if (value === undefined || value === null) return null; if (typeof value === 'string') { if (!value) throw new TypeError('inventory cursor token is empty'); if (value.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized'); return { token: value }; } if (!value || typeof value !== 'object' || typeof (value as InventoryCursor).token !== 'string' || !(value as InventoryCursor).token) throw new TypeError('inventory cursor is malformed'); if ((value as InventoryCursor).token.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized'); return { token: (value as InventoryCursor).token }; } function sameScopeOrder(scopes: readonly string[]): boolean { return scopes.every((scope, index) => index === 0 || scopes[index - 1]! <= scope); } function samePrincipal(left: SelectedResource, right: SelectedResource): boolean { return left.principal.kind === 'character' && right.principal.kind === 'character' && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId; } function sameResource(left: SelectedResource, right: SelectedResource): boolean { return samePrincipal(left, right) && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.requiredScopes.length === right.requiredScopes.length && left.requiredScopes.every((scope, index) => scope === right.requiredScopes[index]); } function resourceSelectionKey(resource: SelectedResource): string { return resource.principal.kind === 'character' ? `character\u0000${resource.principal.ownerUserId}\u0000${resource.principal.id}` : `${resource.principal.kind}\u0000${'id' in resource.principal ? resource.principal.id : ''}`; } function snapshotSelectionKey(resource: InventoryResourceSnapshot): string { return `character\u0000${resource.userId}\u0000${resource.characterId}`; } function sameSnapshotPins(left: InventoryResourceSnapshot, right: InventoryResourceSnapshot): boolean { const leftScopes = left.grantedScopes ?? left.resource.requiredScopes; const rightScopes = right.grantedScopes ?? right.resource.requiredScopes; return left.userId === right.userId && left.characterId === right.characterId && sameResource(left.resource, right.resource) && left.status === right.status && left.generation === right.generation && left.referenceVersion === right.referenceVersion && left.priceVersion === right.priceVersion && left.enrichmentVersion === right.enrichmentVersion && left.grantRevision === right.grantRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion && left.fetchedAt === right.fetchedAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt && leftScopes.length === rightScopes.length && leftScopes.every((scope, index) => scope === rightScopes[index]); } function canonicalResources(resources: readonly SelectedResource[]): SelectedResource[] { return [...resources].sort((left, right) => { if (left.principal.kind !== 'character' || right.principal.kind !== 'character') return 0; return compareDecimalIds(left.principal.id, right.principal.id); }).map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes].sort() })); } function copyCanonicalResources(resources: readonly SelectedResource[]): SelectedResource[] { return resources.map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes] })); } function canonicalSnapshots(resources: readonly InventoryResourceSnapshot[]): InventoryResourceSnapshot[] { return [...resources].sort((left, right) => compareDecimalIds(String(left.characterId), String(right.characterId))); } function digest(value: unknown): string { const text = stableSerialize(value); let first = 2166136261; let second = 2166136261 ^ 0x9e3779b9; for (let index = 0; index < text.length; index += 1) { const code = text.charCodeAt(index); first = Math.imul(first ^ code, 16777619); second = Math.imul(second ^ (code + index), 2246822519); } return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`; } function versionDigest(values: readonly (string | null)[]): string | null { const unique = [...new Set(values)]; if (unique.length === 0 || unique.every((value) => value === null)) return null; return unique.length === 1 ? unique[0]! : `multi-${digest(unique.sort())}`; } function resourceMaps(resources: readonly InventoryResourceSnapshot[]) { const authorizationRevisions: Record = {}; const authorizationEpochs: Record = {}; const snapshotGenerations: Record = {}; const credentialVersions: Record = {}; const enrichmentVersions: Record = {}; for (const resource of canonicalSnapshots(resources)) { const key = `character:${resource.characterId}`; authorizationRevisions[key] = resource.grantRevision; authorizationEpochs[key] = resource.authorizationEpoch; credentialVersions[key] = resource.credentialVersion; if (resource.generation) snapshotGenerations[key] = resource.generation; enrichmentVersions[key] = resource.enrichmentVersion; } return { authorizationRevisions, authorizationEpochs, snapshotGenerations, credentialVersions, enrichmentVersions }; } function queryFingerprint(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): string { const maps = resourceMaps(resources); return `inventory-${digest({ callerUserId: request.callerUserId, resources: copyCanonicalResources(request.selectedResources), filters: { ...request.filters, minQuantity: request.filters.minQuantity?.toString() ?? null, maxQuantity: request.filters.maxQuantity?.toString() ?? null, }, sort: request.sort, pageSize: request.pageSize, ...maps, referenceVersions: canonicalSnapshots(resources).map((resource) => resource.referenceVersion), priceVersions: canonicalSnapshots(resources).map((resource) => resource.priceVersion), })}`; } function cursorPin(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, expiresAt: number): InventoryCursorPin { const maps = resourceMaps(resources); return { callerUserId: request.callerUserId, queryFingerprint: fingerprint, selectedResources: copyCanonicalResources(request.selectedResources), authorizationRevisions: maps.authorizationRevisions, authorizationEpochs: maps.authorizationEpochs, snapshotGenerations: maps.snapshotGenerations, referenceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.referenceVersion)), priceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.priceVersion)), expiresAt, }; } function cursorLookup(token: string, request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, now: number): InventoryCursorLookup { const pin = cursorPin(request, resources, fingerprint, now); return { token, callerUserId: request.callerUserId, queryFingerprint: fingerprint, selectedResources: pin.selectedResources, authorizationRevisions: pin.authorizationRevisions, authorizationEpochs: pin.authorizationEpochs, snapshotGenerations: pin.snapshotGenerations, referenceVersion: pin.referenceVersion, priceVersion: pin.priceVersion, now }; } type NonReadyInventoryResult = Exclude; function invalidCursorResult(failure: CursorFailure, reason: string): NonReadyInventoryResult { return { status: failure, rows: [], summary: null, reason }; } function provenance(resources: readonly InventoryResourceSnapshot[], now: number, priceVersion: string | null, referenceVersion: string | null): CompleteReportProvenance { const orderedResources = canonicalSnapshots(resources); const selectedResources = copyCanonicalResources(orderedResources.map((resource) => resource.resource)); const datasetPins = orderedResources.map((resource) => ({ selectedResource: resource.resource, principal: resource.resource.principal, dataset: 'assets' as const, coverage: { kind: 'snapshot' as const, generation: resource.generation!, complete: true as const }, authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, fetchedAt: resource.fetchedAt, upstreamEffectiveAt: resource.upstreamEffectiveAt, })); const maps = resourceMaps(orderedResources); return { reportId: `inventory-${digest({ selectedResources, datasetPins, now })}`, generatedAt: now, fetchedAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.fetchedAt])), upstreamEffectiveAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.upstreamEffectiveAt])), input: { selectedResources, requiredDatasetKinds: ['assets'] as readonly [DatasetKind], pinSet: 'exact_selected_resource_dataset_set', datasetPins: datasetPins as unknown as CompleteReportProvenance['input']['datasetPins'], authorizationRevisions: maps.authorizationRevisions, authorizationEpochs: maps.authorizationEpochs, snapshotGenerations: maps.snapshotGenerations, appendCheckpoints: {}, referenceVersion, priceVersion, }, sourceSkew: false, assumptions: ['Reference and price inputs are immutable versions pinned by the projection manifest.'], }; } function stripRows(rows: readonly InventoryProjectionRow[]): readonly InventoryRow[] { // Keep only the documented public inventory projection. In particular, never // spread persisted rows: adapters may contain private index fields or // credential-like additions that must not cross the query boundary. return rows.map((row) => ({ itemId: row.itemId, characterId: row.characterId, typeId: row.typeId, quantity: row.quantity, locationId: row.locationId, locationType: row.locationType, locationFlag: row.locationFlag, isSingleton: row.isSingleton, isBlueprintCopy: row.isBlueprintCopy, name: row.name, typeName: row.typeName, categoryId: row.categoryId, groupId: row.groupId, customName: row.customName, systemId: row.systemId, regionId: row.regionId, estimatedValue: row.estimatedValue, unitPrice: row.unitPrice, pricing: row.pricing, unresolvedLocation: row.unresolvedLocation, unresolvedReasons: [...row.unresolvedReasons], location: { itemId: row.location.itemId, locationId: row.location.locationId, sourceLocationId: row.location.sourceLocationId, resolution: row.location.resolution, kind: row.location.kind, name: row.location.name, systemId: row.location.systemId, regionId: row.location.regionId, evidence: row.location.evidence, unresolvedReasons: [...row.location.unresolvedReasons], }, })) as unknown as readonly InventoryRow[]; } function validateRepositoryPage(page: InventoryRepositoryPage, request: NormalizedRequest, offset: number): void { if (!page || typeof page !== 'object' || !['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes((page as { status?: unknown }).status as string) || !Array.isArray(page.rows) || page.rows.length > request.pageSize) throw new TypeError('inventory repository returned an invalid page'); if (!Array.isArray(page.resources) || page.resources.length !== request.selectedResources.length) throw new TypeError('inventory repository returned an invalid resource pin'); if (!Number.isSafeInteger(page.matchingRows) || page.matchingRows < 0 || page.matchingRows > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('inventory repository matching row cap exceeded'); if (page.nextOffset !== null && (!Number.isSafeInteger(page.nextOffset) || page.nextOffset < 0 || page.nextOffset > INVENTORY_QUERY_MAX_MATCHING_ROWS)) throw new TypeError('inventory repository continuation offset is invalid'); if (page.status === 'ready') { if (page.rows.length > page.matchingRows || typeof page.matchingQuantity !== 'string' || page.matchingQuantity.length > MAX_INVENTORY_MONEY_TEXT_LENGTH || !/^(?:0|[1-9][0-9]*)$/.test(page.matchingQuantity) || typeof page.estimatedValue !== 'string' || page.estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new TypeError('inventory repository aggregate is invalid'); parseIsk(page.matchingQuantity); parseIsk(page.estimatedValue); if (parseIsk(page.estimatedValue) < 0n) throw new TypeError('inventory repository aggregate is negative'); const end = offset + page.rows.length; if (end > page.matchingRows) throw new TypeError('inventory repository page exceeds summary'); if (page.rows.length === 0) { if (offset < page.matchingRows || page.nextOffset !== null) throw new TypeError('inventory repository returned an empty page before the end'); } else if (end < page.matchingRows) { if (page.nextOffset !== end) throw new TypeError('inventory repository continuation skips or loops'); } else if (page.nextOffset !== null) { throw new TypeError(`inventory repository returned a continuation after the end (${offset},${page.rows.length},${page.matchingRows},${page.nextOffset})`); } } else if (page.rows.length !== 0 || page.matchingRows !== 0 || page.matchingQuantity !== '0' || page.estimatedValue !== null || page.nextOffset !== null || typeof page.reason !== 'string' || !page.reason) { throw new TypeError('inventory repository unavailable response is invalid'); } } function validateReturnedResourcePins(resources: readonly InventoryResourceSnapshot[], authoritative: readonly InventoryResourceSnapshot[]): void { const authoritativeByKey = new Map(authoritative.map((resource) => [snapshotSelectionKey(resource), resource])); const seen = new Set(); for (const resource of resources) { const key = snapshotSelectionKey(resource); const expected = authoritativeByKey.get(key); if (!expected || seen.has(key) || !sameSnapshotPins(resource, expected)) throw new TypeError('inventory repository resource pin does not match authoritative snapshot'); seen.add(key); } if (seen.size !== authoritativeByKey.size) throw new TypeError('inventory repository resource pin does not match authoritative snapshot'); } function validateRepositoryRows(rows: readonly InventoryProjectionRow[], resources: readonly InventoryResourceSnapshot[]): void { const owners = new Map(resources.map((resource) => [resource.characterId, resource])); const seen = new Set(); for (const row of rows) { if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || !Number.isSafeInteger(row.characterId) || row.characterId <= 0 || typeof row.itemKey !== 'string' || seen.has(row.itemKey) || !Number.isSafeInteger(row.typeId) || row.typeId <= 0 || !Number.isSafeInteger(row.quantity) || row.quantity < 0) throw new TypeError('inventory repository returned a malformed row'); validateQuantity(row.quantity); const owner = owners.get(row.characterId); if (!owner || row.resourceKey !== `character:${row.characterId}` || row.generation !== owner.generation || row.referenceVersion !== owner.referenceVersion || row.priceVersion !== owner.priceVersion || row.enrichmentVersion !== owner.enrichmentVersion) throw new TypeError('inventory repository row pin does not match selected resource'); validateInventoryProjectionRow(row, { userId: owner.userId, resource: owner.resource, authority: { userId: owner.userId, characterId: owner.characterId, grantRevision: owner.grantRevision, authorizationEpoch: owner.authorizationEpoch, credentialVersion: owner.credentialVersion, scopes: owner.grantedScopes }, generation: owner.generation!, referenceVersion: owner.referenceVersion!, priceVersion: owner.priceVersion, enrichmentVersion: owner.enrichmentVersion, }); seen.add(row.itemKey); } } /** Pure-core query orchestration over a bounded, typed persistence port. */ export class InventoryQueryService { private readonly repository: InventoryQueryRepository; private readonly now: () => number; private readonly cursorTtlMs: number; private readonly maxPageSize: number; constructor(options: InventoryQueryServiceOptions); constructor(repository: InventoryQueryRepository, options?: Omit); constructor(optionsOrRepository: InventoryQueryServiceOptions | InventoryQueryRepository, extraOptions: Omit = {}) { const options: InventoryQueryServiceOptions = 'repository' in optionsOrRepository ? optionsOrRepository : { repository: optionsOrRepository, ...extraOptions }; this.repository = options.repository; this.now = options.now ?? (() => Date.now()); this.cursorTtlMs = options.cursorTtlMs ?? DEFAULT_INVENTORY_CURSOR_TTL_MS; this.maxPageSize = options.maxPageSize ?? INVENTORY_QUERY_MAX_PAGE_SIZE; if (!Number.isSafeInteger(this.cursorTtlMs) || this.cursorTtlMs < 1 || this.cursorTtlMs > MAX_INVENTORY_CURSOR_TTL_MS) throw new RangeError('inventory cursor TTL is invalid'); if (!Number.isSafeInteger(this.maxPageSize) || this.maxPageSize < 1 || this.maxPageSize > INVENTORY_QUERY_MAX_PAGE_SIZE) throw new RangeError('inventory page cap is invalid'); } async query(input: InventoryQueryRequest): Promise { const now = finiteNow(this.now); const request = await this.normalize(input); let resources: readonly InventoryResourceSnapshot[]; try { resources = await this.repository.authorizedResources(request.callerUserId); } catch { return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory resource coverage is unavailable' }; } this.validateResources(resources, request.callerUserId); const selected = this.resolveSelection(request, resources); if (selected === null) return invalidCursorResult('authorization_required', 'Inventory resource selection is not authorized for caller'); const resolved: NormalizedRequest = { ...request, selectedResources: selected }; if (selected.length === 0) return invalidCursorResult('authorization_required', 'No authorized linked character resources are available'); const selectedKeys = new Set(selected.map(resourceSelectionKey)); const selectedSnapshots = canonicalSnapshots(resources.filter((resource) => selectedKeys.has(resourceSelectionKey(resource.resource)))); const failure = selectedSnapshots.find((resource) => resource.status !== 'ready'); if (failure) { const failureStatus = failure.status === 'ready' ? 'unavailable' : failure.status; return { status: failureStatus, rows: [], summary: null, reason: failure.reason ?? `Character ${failure.characterId} inventory coverage is unavailable` }; } const fingerprint = queryFingerprint(resolved, selectedSnapshots); let offset = 0; let cursorState: InventoryCursorState | null = null; if (resolved.cursor) { try { cursorState = await this.repository.getCursor(resolved.cursor.token); } catch { return invalidCursorResult('unavailable', 'Inventory cursor storage is unavailable'); } if (!cursorState) return invalidCursorResult('unavailable', 'Inventory cursor is unknown or expired'); try { validateInventoryCursorState(cursorState); } catch { return invalidCursorResult('unavailable', 'Inventory cursor state is malformed'); } if (cursorState.token !== resolved.cursor.token) return invalidCursorResult('unavailable', 'Inventory cursor token envelope is malformed'); const lookup = cursorLookup(resolved.cursor.token, resolved, selectedSnapshots, fingerprint, now); const validation = validateInventoryCursorUse(cursorState, lookup); if (!validation.valid) { const status: CursorFailure = validation.reason === 'caller_mismatch' || validation.reason === 'resource_mismatch' || validation.reason === 'authorization_mismatch' ? 'authorization_required' : validation.reason === 'input_version_mismatch' || validation.reason === 'expired' || validation.reason === 'exhausted' ? 'stale' : 'unavailable'; return invalidCursorResult(status, `Inventory cursor rejected: ${validation.reason}`); } offset = cursorState.position.offset; } const pageRequest: InventoryRepositoryPageRequest = { callerUserId: resolved.callerUserId, selectedResources: resolved.selectedResources, filters: resolved.filters, sort: resolved.sort, offset, pageSize: resolved.pageSize }; let page: InventoryRepositoryPage; try { page = await this.repository.readPage(pageRequest); } catch (error) { if (error instanceof TypeError) throw error; if (error instanceof RangeError) return { status: 'unavailable', rows: [], summary: null, reason: error.message }; return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory query storage is unavailable' }; } validateRepositoryPage(page, resolved, offset); validateReturnedResourcePins(page.resources, selectedSnapshots); if (page.status !== 'ready') return { status: page.status, rows: [], summary: null, reason: page.reason }; if (selectedSnapshots.some((resource) => resource.status !== 'ready')) throw new TypeError('ready repository page contains a non-ready resource'); validateRepositoryRows(page.rows, selectedSnapshots); const summary = { matchingRows: page.matchingRows, matchingQuantity: page.matchingQuantity, estimatedValue: page.estimatedValue, scope: 'all_matching_rows' as const }; if (page.rows.length === 0 && resolved.cursor) { if (!cursorState) throw new TypeError('inventory continuation cursor state is missing'); try { await this.repository.replaceCursor({ ...cursorState, status: 'exhausted' }); } catch { return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' }; } const result: InventoryQueryResult = { status: 'ready', rows: [], summary, nextCursor: null, pageState: 'after_end', provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))) }; if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid after-end page'); return result; } let nextCursor: InventoryCursor | null = null; if (page.rows.length > 0) { const token = randomUUID(); const position = { offset: page.nextOffset ?? offset + page.rows.length, lastItemId: page.rows[page.rows.length - 1]!.itemId }; try { await this.repository.putCursor({ token, status: 'active', position, pin: cursorPin(resolved, selectedSnapshots, fingerprint, now + this.cursorTtlMs) }); } catch { return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' }; } nextCursor = { token }; } const result: InventoryQueryResult = { status: 'ready', rows: stripRows(page.rows), summary, nextCursor, pageState: resolved.cursor ? 'continuation' : 'initial', provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))), } as InventoryQueryResult; if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid page'); return result; } execute(input: InventoryQueryRequest): Promise { return this.query(input); } private async normalize(input: InventoryQueryRequest): Promise { if (!input || typeof input !== 'object' || typeof input.callerUserId !== 'string' || !input.callerUserId) throw new TypeError('inventory caller is required'); const pageSize: unknown = input.pageSize; if (!Number.isSafeInteger(pageSize) || (pageSize as number) < 1 || (pageSize as number) > this.maxPageSize) throw new RangeError(`inventory page size must be 1..${this.maxPageSize}`); const boundedPageSize = pageSize as number; const resourceSelectionExplicit = input.selectedResources !== undefined; if (resourceSelectionExplicit && (!Array.isArray(input.selectedResources) || input.selectedResources.length > INVENTORY_QUERY_MAX_FILTER_VALUES)) throw new RangeError('selected resource cap exceeded'); return { callerUserId: input.callerUserId, selectedResources: resourceSelectionExplicit ? canonicalResources(input.selectedResources!) : [], resourceSelectionExplicit, filters: normalizeFilters(input.filters), sort: normalizeSort(input.sort), pageSize: boundedPageSize, cursor: normalizeCursor(input.cursor), }; } private validateResources(resources: readonly InventoryResourceSnapshot[], userId: string): void { if (!Array.isArray(resources) || resources.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('authorized resource cap exceeded'); const principals = new Set(); for (const resource of resources) { if (!resource || resource.userId !== userId || resource.userId.length > 256 || !resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.ownerUserId !== userId || !Array.isArray(resource.resource.requiredScopes) || resource.resource.requiredScopes.length > 64 || resource.resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.resource.requiredScopes).size !== resource.resource.requiredScopes.length || !sameScopeOrder(resource.resource.requiredScopes) || !resource.resource.requiredScopes.includes(ESI_ASSETS_SCOPE) || !isDecimalId(resource.resource.principal.id) || !Number.isSafeInteger(Number(resource.resource.principal.id)) || resource.characterId !== Number(resource.resource.principal.id) || principals.has(resource.resource.principal.id)) throw new TypeError('authorized resource repository returned malformed or cross-tenant data'); if (!['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes(resource.status)) throw new TypeError('authorized resource status is invalid'); if (!Number.isSafeInteger(resource.grantRevision) || resource.grantRevision < 0 || !Number.isSafeInteger(resource.authorizationEpoch) || resource.authorizationEpoch < 0 || !Number.isSafeInteger(resource.credentialVersion) || resource.credentialVersion < 0 || resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('authorized resource counters are stale or inconsistent'); if (resource.grantedScopes !== undefined && (!Array.isArray(resource.grantedScopes) || resource.grantedScopes.length > 64 || resource.grantedScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.grantedScopes).size !== resource.grantedScopes.length || !sameScopeOrder(resource.grantedScopes) || !resource.grantedScopes.includes(ESI_ASSETS_SCOPE) || resource.resource.requiredScopes.some((scope: string) => !resource.grantedScopes!.includes(scope)))) throw new TypeError('authorized resource scopes are inconsistent'); if (resource.status === 'ready' && resource.grantedScopes === undefined) throw new TypeError('ready resource is missing current authority scopes'); if (resource.status === 'ready' && (typeof resource.generation !== 'string' || !resource.generation || typeof resource.referenceVersion !== 'string' || !resource.referenceVersion)) throw new TypeError('ready resource is missing an immutable input pin'); if (resource.generation !== null && (typeof resource.generation !== 'string' || !resource.generation || resource.generation.length > 256)) throw new TypeError('resource generation is invalid'); if (resource.referenceVersion !== null && (typeof resource.referenceVersion !== 'string' || !resource.referenceVersion || resource.referenceVersion.length > 256)) throw new TypeError('resource reference pin is invalid'); if (resource.priceVersion !== null && (typeof resource.priceVersion !== 'string' || !resource.priceVersion || resource.priceVersion.length > 256)) throw new TypeError('resource price pin is invalid'); if (resource.enrichmentVersion !== null && (typeof resource.enrichmentVersion !== 'string' || !resource.enrichmentVersion || resource.enrichmentVersion.length > 256)) throw new TypeError('resource enrichment pin is invalid'); if (resource.characterName !== undefined && (typeof resource.characterName !== 'string' || resource.characterName.length > 512)) throw new TypeError('resource character name is invalid'); if (resource.reason !== undefined && (typeof resource.reason !== 'string' || resource.reason.length > 512)) throw new TypeError('resource availability reason is invalid'); if (resource.fetchedAt !== null && !Number.isFinite(resource.fetchedAt)) throw new TypeError('resource fetched timestamp is invalid'); if (resource.upstreamEffectiveAt !== null && !Number.isFinite(resource.upstreamEffectiveAt)) throw new TypeError('resource upstream timestamp is invalid'); principals.add(resource.resource.principal.id); } } private resolveSelection(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): readonly SelectedResource[] | null { if (!request.resourceSelectionExplicit) return canonicalResources(resources.map((resource) => resource.resource)); if (request.selectedResources.length === 0) return null; const available = new Map(resources.map((snapshot) => [resourceSelectionKey(snapshot.resource), snapshot.resource])); const selected = new Map(); for (const candidate of request.selectedResources) { const key = resourceSelectionKey(candidate); if (selected.has(key)) throw new TypeError('inventory resource selection contains duplicate characters'); const authorized = available.get(key); if (!authorized || !sameResource(candidate, authorized)) return null; selected.set(key, candidate); } return request.selectedResources; } } export async function queryInventory(options: InventoryQueryServiceOptions, request: InventoryQueryRequest): Promise { return new InventoryQueryService(options).query(request); } export { normalizeFilters, normalizeSort };