feat: add pinned inventory location enrichment
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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<PrivateStructureAccessObservation>;
|
||||
export interface PrivateStructurePort {
|
||||
access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation>;
|
||||
}
|
||||
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<PrivateStructureContext>;
|
||||
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<PrivateStructureAccessObservation>;
|
||||
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<string, Entry>();
|
||||
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<PrivateStructureAccessObservation> {
|
||||
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<PrivateStructureContext> & { 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> | string),
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
private readonly ttlMs = 60 * 60 * 1000,
|
||||
private readonly hooks?: { beforeAccess?: () => Promise<void>; onTransient?: (error: unknown) => Promise<void> },
|
||||
) {}
|
||||
|
||||
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
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<void>) {}
|
||||
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
await this.beforeAccess?.();
|
||||
return await this.cache.getOrLoad(context, structureId, this.reader);
|
||||
}
|
||||
read(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
|
||||
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;
|
||||
Reference in New Issue
Block a user