import { assertDecimalId, stableSerialize, validateAuthorityContext, type DecimalId, type InventoryLocationAuthority, } from '../../domain/inventory-location'; import { ESI_STRUCTURES_SCOPE, EsiClient, EsiError, EsiHttpError, EsiRateLimitError, type AuthorizationKey } from '../esi'; export type PrivateStructureContext = InventoryLocationAuthority & { referenceVersion: string }; /** * This is deliberately an access observation, not an ACL or docking result. * A successful ESI structure read proves that ESI returned data for this * character-scoped request; it does not prove docking, tethering, or docking * permission. The literal `unknown` is therefore part of every variant. */ export type PrivateStructureAccessObservation = { structureId: DecimalId; characterId: number; status: 'accessible' | 'forbidden' | 'unavailable'; evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable'; name: string | null; observedAt: number; expiresAt: number; revision?: string; dockingPermission: 'unknown'; reason?: string; /** Optional facts returned by the trusted structure endpoint. */ solarSystemId?: DecimalId | null; ownerId?: DecimalId | null; typeId?: DecimalId | null; position?: { x: number; y: number; z: number } | null; }; export type PrivateStructureReader = (context: PrivateStructureContext, structureId: DecimalId) => Promise; export interface PrivateStructurePort { access(context: PrivateStructureContext, structureId: DecimalId): Promise; } export type PrivateStructureCacheOptions = { now?: () => number; maxEntries?: number; maxBytes?: number; }; const DEFAULT_MAX_ENTRIES = 128; const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; const MAX_NAME_LENGTH = 512; const MAX_REASON_LENGTH = 256; function nowOf(clock: () => number): number { const value = clock(); if (!Number.isFinite(value)) throw new RangeError('cache clock must return a finite timestamp'); return value; } function validCounter(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } export function validatePrivateStructureContext(value: unknown): PrivateStructureContext { if (!value || typeof value !== 'object') throw new TypeError('private structure context is required'); const candidate = value as Partial; const authority = validateAuthorityContext(candidate); if (typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion) throw new TypeError('reference version is required for private structure access'); return { ...authority, referenceVersion: candidate.referenceVersion }; } export function privateStructureCacheKey(context: PrivateStructureContext, structureId: DecimalId): string { const valid = validatePrivateStructureContext(context); assertDecimalId(structureId, 'structure ID'); return stableSerialize({ userId: valid.userId, principal: valid.principal, characterId: valid.characterId, scopes: [...valid.scopes].sort(), grantRevision: valid.grantRevision, authorizationEpoch: valid.authorizationEpoch, credentialVersion: valid.credentialVersion, referenceVersion: valid.referenceVersion, structureId, }); } function validateObservation(value: unknown, context: PrivateStructureContext, structureId: DecimalId, now: number): PrivateStructureAccessObservation { if (!value || typeof value !== 'object') throw new TypeError('private structure reader returned invalid data'); const candidate = value as Partial; assertDecimalId(candidate.structureId, 'structure observation ID'); if (candidate.structureId !== structureId) throw new TypeError('private structure observation ID does not match request'); if (candidate.characterId !== context.characterId) throw new TypeError('private structure observation character mismatch'); if (candidate.status !== 'accessible' && candidate.status !== 'forbidden' && candidate.status !== 'unavailable') throw new TypeError('private structure observation status is invalid'); if (candidate.evidence !== 'esi_structure_access' && candidate.evidence !== 'esi_forbidden' && candidate.evidence !== 'unavailable') throw new TypeError('private structure observation evidence is invalid'); if (candidate.status === 'accessible' && candidate.evidence !== 'esi_structure_access') throw new TypeError('accessible structure observation has invalid evidence'); if (candidate.status === 'forbidden' && candidate.evidence !== 'esi_forbidden') throw new TypeError('forbidden structure observation has invalid evidence'); if (candidate.status === 'unavailable' && candidate.evidence !== 'unavailable') throw new TypeError('unavailable structure observation has invalid evidence'); if (candidate.dockingPermission !== 'unknown') throw new TypeError('structure access cannot assert docking permission'); if (typeof candidate.name !== 'string' && candidate.name !== null) throw new TypeError('private structure name is invalid'); if (typeof candidate.name === 'string' && (candidate.name.length > MAX_NAME_LENGTH || !candidate.name.trim())) throw new TypeError('private structure name is invalid'); if (candidate.status !== 'accessible' && candidate.name !== null) throw new TypeError('private structure denial cannot carry a private name'); const observedAt = candidate.observedAt; const expiresAt = candidate.expiresAt; if (typeof observedAt !== 'number' || typeof expiresAt !== 'number' || !Number.isFinite(observedAt) || !Number.isFinite(expiresAt) || expiresAt < observedAt) throw new TypeError('private structure freshness is invalid'); if (expiresAt <= now) throw new TypeError('expired private structure observations are not cacheable'); if (candidate.reason !== undefined && (typeof candidate.reason !== 'string' || candidate.reason.length > MAX_REASON_LENGTH)) throw new TypeError('private structure reason is invalid'); if (candidate.revision !== undefined && (typeof candidate.revision !== 'string' || !candidate.revision)) throw new TypeError('private structure revision is invalid'); for (const [field, value] of [['solarSystemId', candidate.solarSystemId], ['ownerId', candidate.ownerId], ['typeId', candidate.typeId] ] as const) { if (value !== undefined && value !== null) assertDecimalId(value, field); } if (candidate.position !== undefined && candidate.position !== null) { if (typeof candidate.position !== 'object' || !Number.isFinite(candidate.position.x) || !Number.isFinite(candidate.position.y) || !Number.isFinite(candidate.position.z)) throw new TypeError('private structure position is invalid'); } if (candidate.status !== 'accessible' && (candidate.solarSystemId !== undefined || candidate.ownerId !== undefined || candidate.typeId !== undefined || candidate.position !== undefined)) throw new TypeError('private structure denial cannot carry private facts'); return { structureId, characterId: context.characterId, status: candidate.status, evidence: candidate.evidence, name: candidate.name ?? null, observedAt, expiresAt, ...(candidate.revision === undefined ? {} : { revision: candidate.revision }), dockingPermission: 'unknown', ...(candidate.reason === undefined ? {} : { reason: candidate.reason }), ...(candidate.solarSystemId === undefined ? {} : { solarSystemId: candidate.solarSystemId }), ...(candidate.ownerId === undefined ? {} : { ownerId: candidate.ownerId }), ...(candidate.typeId === undefined ? {} : { typeId: candidate.typeId }), ...(candidate.position === undefined ? {} : { position: candidate.position === null ? null : { ...candidate.position } }), }; } type Entry = { key: string; value: PrivateStructureAccessObservation; bytes: number; touchedAt: number }; /** Bounded, authority-isolated process cache for private structure observations. */ export class PrivateStructureCache { private readonly entries = new Map(); private bytes = 0; private readonly now: () => number; private readonly maxEntries: number; private readonly maxBytes: number; constructor(options: PrivateStructureCacheOptions = {}) { this.now = options.now ?? (() => Date.now()); this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1 || this.maxEntries > DEFAULT_MAX_ENTRIES) throw new RangeError('private structure cache entry cap is invalid'); if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1024 || this.maxBytes > DEFAULT_MAX_BYTES) throw new RangeError('private structure cache byte cap is invalid'); } get size(): number { return this.entries.size; } get byteSize(): number { return this.bytes; } stats(): { entries: number; bytes: number; maxEntries: number; maxBytes: number } { return { entries: this.entries.size, bytes: this.bytes, maxEntries: this.maxEntries, maxBytes: this.maxBytes }; } get(context: PrivateStructureContext, structureId: DecimalId): PrivateStructureAccessObservation | null { const key = privateStructureCacheKey(context, structureId); const entry = this.entries.get(key); if (!entry) return null; const now = nowOf(this.now); if (entry.value.expiresAt <= now) { this.delete(key); return null; } // Touching an entry makes eviction LRU while retaining deterministic order. this.entries.delete(key); entry.touchedAt = now; this.entries.set(key, entry); return { ...entry.value }; } set(context: PrivateStructureContext, structureId: DecimalId, value: PrivateStructureAccessObservation): boolean { const key = privateStructureCacheKey(context, structureId); const now = nowOf(this.now); const checked = validateObservation(value, validatePrivateStructureContext(context), structureId, now); const bytes = new TextEncoder().encode(JSON.stringify({ key, value: checked })).byteLength; if (bytes > this.maxBytes) return false; this.delete(key); this.entries.set(key, { key, value: checked, bytes, touchedAt: now }); this.bytes += bytes; this.evict(); return this.entries.has(key); } async getOrLoad(context: PrivateStructureContext, structureId: DecimalId, reader: PrivateStructureReader): Promise { const hit = this.get(context, structureId); if (hit) return hit; const validContext = validatePrivateStructureContext(context); const value = await reader(validContext, structureId); const checked = validateObservation(value, validContext, structureId, nowOf(this.now)); this.set(validContext, structureId, checked); return { ...checked }; } /** Remove one authority key. A revision change can use invalidateAuthority. */ invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { const valid = validatePrivateStructureContext(context); if (structureId !== undefined) { const key = privateStructureCacheKey(valid, structureId); return this.delete(key) ? 1 : 0; } return this.invalidateAuthority(valid); } /** Invalidate every revision/scope entry for this user/principal/character. */ invalidateAuthority(context: PrivateStructureContext): number { const valid = validatePrivateStructureContext(context); let removed = 0; for (const [key, entry] of this.entries) { // Keys are stable JSON; parsing is safe because all keys are generated here. const parsed = JSON.parse(key) as Partial & { structureId?: string }; if (parsed.userId === valid.userId && parsed.principal === valid.principal && parsed.characterId === valid.characterId) { this.delete(key); removed += 1; } } return removed; } clear(): void { this.entries.clear(); this.bytes = 0; } private delete(key: string): boolean { const entry = this.entries.get(key); if (!entry) return false; this.entries.delete(key); this.bytes -= entry.bytes; return true; } private evict(): void { while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) { const oldest = this.entries.keys().next().value as string | undefined; if (oldest === undefined) break; this.delete(oldest); } } } /** A port that adds the bounded cache to a typed private ESI reader. */ /** * Adapter from the fixed ESI structure operation to the authority-isolated * cache port. It accepts a token provider rather than retaining tokens in the * cache or its keys. */ export class EsiPrivateStructurePort implements PrivateStructurePort { constructor( private readonly esi: EsiClient, private readonly accessToken: string | ((context: PrivateStructureContext) => Promise | string), private readonly now: () => number = () => Date.now(), private readonly ttlMs = 60 * 60 * 1000, private readonly hooks?: { beforeAccess?: () => Promise; onTransient?: (error: unknown) => Promise }, ) {} async access(context: PrivateStructureContext, structureId: DecimalId): Promise { const valid = validatePrivateStructureContext(context); assertDecimalId(structureId, 'structure ID'); await this.hooks?.beforeAccess?.(); const observedAt = this.now(); if (!Number.isFinite(observedAt)) throw new RangeError('private structure adapter clock is invalid'); if (!valid.scopes.includes(ESI_STRUCTURES_SCOPE)) { return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'required structure scope is not granted' }; } const token = typeof this.accessToken === 'function' ? await this.accessToken(valid) : this.accessToken; try { const observation = await this.esi.getStructure(valid.characterId, structureId, token, valid as AuthorizationKey); return { structureId, characterId: valid.characterId, status: 'accessible', evidence: 'esi_structure_access', name: observation.name, observedAt: observation.retrievedAt, expiresAt: observation.expiresAt, dockingPermission: 'unknown', solarSystemId: observation.solarSystemId, ownerId: observation.ownerId, typeId: observation.typeId, position: observation.position, }; } catch (error) { if (error instanceof EsiHttpError && error.status === 403) { return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI denied structure access' }; } if (error instanceof EsiHttpError && error.status === 404) { return { structureId, characterId: valid.characterId, status: 'unavailable', evidence: 'unavailable', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI did not return the structure' }; } if (error instanceof EsiRateLimitError || (error instanceof EsiError && (error.code === 'network' || error.code === 'deadline' || (error instanceof EsiHttpError && (error.status === 408 || error.status === 420 || error.status === 425 || error.status === 429 || error.status >= 500))))) await this.hooks?.onTransient?.(error); // Transport/deadline/rate-limit failures remain errors so the worker can // preserve retry/cooldown semantics; explicit 403/404 are observations. if (error instanceof EsiError) throw error; throw new Error('private structure lookup failed'); } } } export class CachedPrivateStructurePort { constructor(private readonly reader: PrivateStructureReader, private readonly cache = new PrivateStructureCache(), private readonly beforeAccess?: () => Promise) {} async access(context: PrivateStructureContext, structureId: DecimalId): Promise { await this.beforeAccess?.(); return await this.cache.getOrLoad(context, structureId, this.reader); } read(context: PrivateStructureContext, structureId: DecimalId): Promise { return this.access(context, structureId); } invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { return this.cache.invalidate(context, structureId); } getCache(): PrivateStructureCache { return this.cache; } } export const AuthIsolatedPrivateStructureCache = PrivateStructureCache; export const PrivateStructureAccessCache = PrivateStructureCache;