Files
eden/src/lib/server/inventory/private-structure-cache.ts
T

292 lines
16 KiB
TypeScript

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;