feat: add pinned inventory location enrichment

This commit is contained in:
jb
2026-09-20 12:13:51 -04:00
parent fe7d218195
commit 0c9197f841
32 changed files with 3116 additions and 55 deletions
+376 -9
View File
@@ -5,7 +5,8 @@ import {
} from "convex/server";
import { v } from "convex/values";
import { assetValidator } from "./schema";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
import { deterministicEnrichmentVersion, MAX_LOCATION_ENRICHMENT_CHUNK, resolveContainment, validatePersistedLocationRows, validateReferenceEnrichmentPin } from "../lib/domain/inventory-location";
import { capabilityEnabled, parseBackendCapabilityAllowlist, type ReleaseCapability } from "../lib/server/release-gate";
import {
CAPABILITY_DEFINITIONS,
SERVER_SCOPE_ALLOWLIST,
@@ -14,6 +15,17 @@ import {
import type { Capability } from "../lib/domain/model";
const MAX_ASSET_CHUNK = 250;
const MAX_LOCATION_CHUNK = MAX_LOCATION_ENRICHMENT_CHUNK;
const MAX_LOCATION_CHUNK_BYTES = 512 * 1024;
const MAX_ENRICHMENT_TOTAL_BYTES = 16 * 1024 * 1024;
// Initial T08 persistence cap is deliberately conservative: larger optional
// enrichment falls back to a truthful failed/partial result while raw assets
// remain publishable. Increase only with measured production transaction data.
const MAX_PERSISTED_ENRICHMENT_ROWS = 2_500;
// A 50k generation can retain both helper ID plans without truncation; the
// nested validator below still caps every list and scalar independently.
const MAX_ENRICHMENT_PIN_BYTES = 8 * 1024 * 1024;
const MAX_ASSET_ROWS = 50_000;
const SYNC_LEASE_MS = 5 * 60 * 1000;
// Convex transactions must validate the manifest within bounded read limits.
const MAX_CHUNKS = 4_096;
@@ -43,6 +55,24 @@ const assetPageResultValidator = v.object({
isDone: v.boolean(),
continueCursor: v.string(),
generation: v.union(v.string(), v.null()),
enrichmentStatus: v.union(v.string(), v.null()),
enrichmentVersion: v.union(v.string(), v.null()),
snapshotChanged: v.boolean(),
});
const enrichmentManifestValidator = v.object({
generation: v.string(),
status: v.union(v.string(), v.null()),
version: v.union(v.string(), v.null()),
inputPin: v.union(v.any(), v.null()),
unresolvedCount: v.union(v.number(), v.null()),
});
const enrichmentPageValidator = v.object({
chunks: v.array(v.object({ index: v.number(), rows: v.array(v.any()) })),
isDone: v.boolean(),
continueCursor: v.string(),
generation: v.union(v.string(), v.null()),
status: v.union(v.string(), v.null()),
version: v.union(v.string(), v.null()),
});
function now(): number {
@@ -66,7 +96,7 @@ function assertSyncWorker(secret: string): void {
assertDeploymentCapability('asset_sync');
}
function assertDeploymentCapability(capability: 'asset_sync'): void {
function assertDeploymentCapability(capability: ReleaseCapability): void {
let enabled = false;
try {
enabled = capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
@@ -127,6 +157,21 @@ function scopesEqual(left: readonly string[], right: readonly string[]): boolean
return left.length === right.length && [...left].sort().every((scope, index) => scope === [...right].sort()[index]);
}
function enrichmentAuthorityMatches(character: any, generation: any): boolean {
if (!character || !generation || character.authorizationStatus !== 'active'
|| generation.grantRevision !== character.grantRevision
|| generation.authorizationEpoch !== character.authorizationEpoch
|| generation.credentialVersion !== character.credentialVersion) return false;
const pin = generation.enrichmentInputPin;
if (!pin) return generation.enrichmentStatus === 'failed' || generation.enrichmentStatus === 'unavailable';
const authority = pin.authority;
return Boolean(authority && authority.userId === character.userId && authority.principal === character.ownerHash && authority.characterId === character.characterId
&& authority.grantRevision === character.grantRevision
&& authority.authorizationEpoch === character.authorizationEpoch
&& authority.credentialVersion === character.credentialVersion
&& Array.isArray(authority.scopes) && scopesEqual(authority.scopes, character.scopes));
}
function validActualScopes(scopes: readonly string[]): boolean {
return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope));
}
@@ -288,6 +333,7 @@ export const bridgeCreateOAuth = mutationGeneric({
}
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
@@ -388,6 +434,7 @@ export const bridgeCompleteLogin = mutationGeneric({
if (args.scopes.some((scope) => !args.requestedScopes.includes(scope))) throw new Error("Granted scopes exceed the bound request");
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
@@ -725,12 +772,14 @@ export const bridgeAssetsPage = queryGeneric({
&& assetStatus.authorizationEpoch === character.authorizationEpoch;
const generation = authorized ? character.activeGeneration ?? null : null;
if (!character || !generation) {
return { page: [], isDone: true, continueCursor: "", generation: null };
return { page: [], isDone: true, continueCursor: "", generation: null, enrichmentStatus: null, enrichmentVersion: null, snapshotChanged: false };
}
// The caller pins the first page's generation. Returning the new generation
// without its data prevents a read from silently mixing two snapshots.
const generationRecord = await ctx.db.query("assetGenerations").withIndex("by_user_character_generation", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId).eq("generation", generation)).first();
const enrichmentVisible = generationRecord ? enrichmentAuthorityMatches(character, generationRecord) : false;
if (args.expectedGeneration !== undefined && args.expectedGeneration !== generation) {
return { page: [], isDone: true, continueCursor: "", generation };
return { page: [], isDone: true, continueCursor: "", generation, enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null, enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null, snapshotChanged: true };
}
const page = await ctx.db
.query("assetChunks")
@@ -747,10 +796,45 @@ export const bridgeAssetsPage = queryGeneric({
isDone: page.isDone,
continueCursor: page.continueCursor,
generation,
enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null,
enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null,
snapshotChanged: false,
};
},
});
export const bridgeEnrichmentManifest = queryGeneric({
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.optional(v.string()) },
returns: v.union(enrichmentManifestValidator, v.null()),
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
const character = await findCharacter(ctx, args.userId, args.characterId);
const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === 'assets');
if (!character || character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1') || !assetStatus || (assetStatus.status !== 'ready' && assetStatus.status !== 'error') || assetStatus.grantRevision !== character.grantRevision || assetStatus.authorizationEpoch !== character.authorizationEpoch) return null;
const generation = character.activeGeneration;
if (!generation || (args.expectedGeneration !== undefined && args.expectedGeneration !== generation)) return null;
const record = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', generation)).first();
if (!record || !enrichmentAuthorityMatches(character, record)) return null;
return { generation, status: record.enrichmentStatus ?? null, version: record.enrichmentVersion ?? null, inputPin: record.enrichmentInputPin ?? null, unresolvedCount: record.enrichmentUnresolvedCount ?? null };
},
});
export const bridgeEnrichmentChunksPage = queryGeneric({
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.string(), paginationOpts: paginationOptsValidator },
returns: enrichmentPageValidator,
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
const character = await findCharacter(ctx, args.userId, args.characterId);
const authorized = character?.authorizationStatus === 'active' && character.scopes.includes('esi-assets.read_assets.v1');
const activeGeneration = authorized ? character?.activeGeneration ?? null : null;
if (!character || activeGeneration !== args.expectedGeneration) return { chunks: [], isDone: true, continueCursor: '', generation: activeGeneration, status: null, version: null };
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', activeGeneration)).first();
if (!generation || generation.status !== 'complete' || !enrichmentAuthorityMatches(character, generation)) return { chunks: [], isDone: true, continueCursor: '', generation: null, status: null, version: null };
const page = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', args.expectedGeneration)).order('asc').paginate(args.paginationOpts);
return { chunks: page.page.map((chunk: any) => ({ index: chunk.index, rows: chunk.rows })), isDone: page.isDone, continueCursor: page.continueCursor, generation: args.expectedGeneration, status: generation.enrichmentStatus ?? null, version: generation.enrichmentVersion ?? null };
},
});
export const bridgeBeginSync = mutationGeneric({
args: {
...bridgeArgs,
@@ -1335,7 +1419,7 @@ export const bridgeBeginSyncJob = mutationGeneric({
}
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > now()) throw new Error('Character synchronization is already leased');
const generation = newIdentifier('generation');
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, stagedAssetRowCount: 0, enrichmentRowCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'syncing', grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'syncing', datasetStatuses: statuses, syncGeneration: generation, syncLeaseExpiresAt: now() + 10 * 60 * 1000, error: undefined });
await ctx.db.patch(job._id, { generation, updatedAt: now() });
@@ -1364,18 +1448,235 @@ export const bridgeStageSyncAssets = mutationGeneric({
return null;
}
if (args.index !== stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
const localIds = new Set<string>();
for (const asset of args.assets) {
if (localIds.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
localIds.add(asset.itemId);
const existingFact = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', asset.itemId)).first();
if (existingFact) throw new Error('Snapshot contains duplicate asset IDs');
}
await ctx.db.insert('assetChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, assets: args.assets });
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1 });
for (const asset of args.assets) await ctx.db.insert('assetFacts', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: asset.itemId, typeId: asset.typeId, quantity: asset.quantity, locationId: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag, ...(asset.isSingleton === undefined ? {} : { isSingleton: asset.isSingleton }), ...(asset.isBlueprintCopy === undefined ? {} : { isBlueprintCopy: asset.isBlueprintCopy }) });
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1, stagedAssetRowCount: (generation.stagedAssetRowCount ?? 0) + args.assets.length });
return null;
},
});
export const bridgeResetSyncEnrichment = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, job);
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
const items = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
for (const chunk of chunks) await ctx.db.delete(chunk._id);
for (const item of items) await ctx.db.delete(item._id);
await ctx.db.patch(generation._id, { enrichmentStatus: undefined, enrichmentChunkCount: 0, enrichmentRowCount: 0, enrichmentByteCount: 0 });
return null;
},
});
export const bridgeStageSyncEnrichment = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), index: v.number(), rows: v.array(v.any()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!Number.isInteger(args.index) || args.index < 0 || args.rows.length > MAX_LOCATION_CHUNK) throw new Error('Invalid location enrichment chunk');
const chunkBytes = boundedJsonBytes(args.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
const job = await jobLease(ctx, args.jobId, args.leaseToken);
const character = await jobCharacter(ctx, job);
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
try { validatePersistedLocationRows(args.rows, { authority: { userId: job.userId, principal: character.ownerHash, characterId: job.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion } }); } catch { throw new Error('Invalid location enrichment rows'); }
const existing = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('index', args.index)).first();
if (existing) {
if (JSON.stringify(existing.rows) !== JSON.stringify(args.rows)) throw new Error('Location enrichment chunk already exists');
return null;
}
const localIds = new Set<string>();
for (const row of args.rows) {
if (localIds.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
localIds.add(row.itemId);
const asset = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
assertLocationRowMatchesAsset(row, asset, job.characterId);
const existingItem = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
if (existingItem) throw new Error('Location enrichment contains duplicate item IDs');
}
const stagedChunkCount = generation.enrichmentChunkCount ?? 0;
if (stagedChunkCount >= MAX_CHUNKS || args.index > stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
if (args.index !== stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
await ctx.db.insert('locationEnrichmentChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, rows: args.rows });
for (const row of args.rows) await ctx.db.insert('locationEnrichmentItems', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: row.itemId });
const totalRows = (generation.enrichmentRowCount ?? 0) + args.rows.length;
if (totalRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
const totalBytes = (generation.enrichmentByteCount ?? 0) + chunkBytes;
if (totalBytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
await ctx.db.patch(generation._id, { enrichmentStatus: 'staging', enrichmentChunkCount: stagedChunkCount + 1, enrichmentRowCount: (generation.enrichmentRowCount ?? 0) + args.rows.length, enrichmentByteCount: totalBytes });
return null;
},
});
function boundedJsonBytes(value: unknown, limit: number, field: string): number {
let encoded: string;
try { encoded = JSON.stringify(value); } catch { throw new Error(`${field} is not serializable`); }
const bytes = new TextEncoder().encode(encoded).byteLength;
if (bytes > limit) throw new Error(`${field} exceeds byte bound`);
return bytes;
}
async function stagedAssetFacts(ctx: any, job: any, generation: any): Promise<Map<string, any>> {
if ((generation.stagedChunkCount ?? 0) > MAX_CHUNKS) throw new Error('Snapshot has too many asset chunks');
const expectedRows = generation.stagedAssetRowCount ?? 0;
if (!Number.isSafeInteger(expectedRows) || expectedRows < 0 || expectedRows > MAX_ASSET_ROWS) throw new Error('Snapshot exceeds asset row bound');
// Convex permits only one paginate() per function; the immutable manifest
// count is the bounded read limit, so never use an unbounded collect here.
if (expectedRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment source row bound exceeded');
const factsRows = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).take(MAX_PERSISTED_ENRICHMENT_ROWS + 1);
if (factsRows.length !== expectedRows) throw new Error('Snapshot asset facts are incomplete');
const facts = new Map<string, any>();
for (const asset of factsRows) {
if (facts.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
facts.set(asset.itemId, asset);
}
return facts;
}
function sameOptionalFact(left: unknown, right: unknown): boolean { return left === right || (left === undefined && right === undefined); }
function assertLocationRowMatchesAsset(row: any, asset: any, characterId: number): void {
if (row.characterId !== characterId || row.itemId !== asset.itemId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || !sameOptionalFact(row.isSingleton, asset.isSingleton) || !sameOptionalFact(row.isBlueprintCopy, asset.isBlueprintCopy)) throw new Error('Location enrichment row does not match staged asset facts');
}
function assertCanonicalContainment(rows: readonly any[], facts: Map<string, any>, characterId: number): void {
const assets = [...facts.values()].map((asset) => ({ ...asset, characterId }));
const canonical = resolveContainment({ assets: assets as any, characterId });
const byId = new Map(canonical.nodes.map((node) => [node.itemId, node]));
for (const row of rows) {
const expected = byId.get(row.itemId);
const sameRoot = expected && row.root.kind === expected.root.kind && row.root.id === expected.root.id && row.root.locationType === expected.root.locationType && row.root.locationFlag === expected.root.locationFlag;
if (!expected || !sameRoot || row.parentItemId !== expected.parentItemId || row.depth !== expected.depth || row.fitted.classification !== expected.fitted.classification || row.fitted.rawFlag !== expected.fitted.rawFlag || row.fitted.reason !== expected.fitted.reason || row.fittedClassification !== expected.fittedClassification || row.isFitted !== expected.isFitted || row.breadcrumbs.length !== expected.breadcrumbs.length || row.breadcrumbs.some((breadcrumb: any, index: number) => breadcrumb.itemId !== expected.breadcrumbs[index]?.itemId || breadcrumb.typeId !== expected.breadcrumbs[index]?.typeId)) throw new Error('Location enrichment containment projection is not canonical');
const rowReasons = new Set<string>(row.unresolved);
if (expected.unresolved.some((reason) => !rowReasons.has(reason))) throw new Error('Location enrichment containment reasons are incomplete');
const expectedLocationId = expected.root.id ?? expected.locationId;
if (row.location.id !== expectedLocationId) throw new Error('Location enrichment root location is inconsistent');
const expectedKinds: Record<string, readonly string[]> = { station: ['station', 'unknown'], solar_system: ['system', 'unknown'], other: ['structure', 'unknown'], item: ['unknown'] };
if (!(expectedKinds[expected.root.locationType] ?? ['unknown']).includes(row.location.kind)) throw new Error('Location enrichment root kind is inconsistent');
}
}
async function stagedLocationRows(ctx: any, job: any, generation: any, facts: Map<string, any>, authority: any): Promise<{ rows: any[]; ids: Set<string> }> {
const expectedChunks = generation.enrichmentChunkCount ?? 0;
if (!Number.isInteger(expectedChunks) || expectedChunks < 0 || expectedChunks > MAX_CHUNKS) throw new Error('Location enrichment chunks are incomplete');
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).order('asc').take(expectedChunks);
if (chunks.length !== expectedChunks) throw new Error('Location enrichment chunks are incomplete');
const rows: any[] = [];
const ids = new Set<string>();
let bytes = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (chunk.index !== index || !Array.isArray(chunk.rows) || chunk.rows.length > MAX_LOCATION_CHUNK) throw new Error('Location enrichment chunks are malformed');
if (rows.length + chunk.rows.length > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
bytes += boundedJsonBytes(chunk.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
if (bytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
validatePersistedLocationRows(chunk.rows, { authority });
for (const row of chunk.rows) {
if (ids.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
const asset = facts.get(row.itemId);
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
assertLocationRowMatchesAsset(row, asset, job.characterId);
ids.add(row.itemId); rows.push(row);
}
}
if ((generation.enrichmentByteCount ?? 0) !== bytes || (generation.enrichmentRowCount ?? 0) !== rows.length) throw new Error('Location enrichment row manifest is inconsistent');
return { rows, ids };
}
function validateLocationRowPinAgreement(rows: readonly any[], pin: any, characterId: number): number {
const unresolved = new Set<string>();
const namesRequested = new Set<string>(pin.names.requestedIds);
const namesResolved = new Set<string>(pin.names.resolvedIds);
const namesMissing = new Set<string>(pin.names.missingIds);
const namesBatchStates = new Map<string, string>((pin.names.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
const locationsRequested = new Set<string>(pin.locations.requestedIds);
const locationsResolved = new Set<string>(pin.locations.resolvedIds);
const locationsMissing = new Set<string>(pin.locations.missingIds);
const locationsBatchStates = new Map<string, string>((pin.locations.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
const structurePins = new Map<string, any>((pin.structures ?? []).map((observation: any) => [observation.structureId, observation]));
const referenceFacts = new Map<string, any>((pin.referenceFacts ?? []).map((fact: any) => [`${fact.kind}:${fact.id}`, fact]));
const visitReference = (reference: any): void => {
if (!reference || typeof reference !== 'object' || reference.referenceVersion !== pin.referenceVersion) throw new Error('Location row reference version does not match enrichment pin');
if (reference.kind === 'structure') {
const observation = structurePins.get(reference.id);
if (!observation || reference.name !== observation.name || reference.observedAt !== observation.observedAt || reference.expiresAt !== observation.expiresAt || (reference.system?.id !== undefined && reference.system.id !== observation.solarSystemId) || (reference.evidence === 'private_structure_access' && observation.status !== 'accessible') || (reference.evidence === 'unknown' && observation.status === 'accessible')) throw new Error('Structure row facts/freshness/source does not match enrichment pin');
} else if (reference.kind === 'station' || reference.kind === 'system' || reference.kind === 'region') {
const fact = referenceFacts.get(`${reference.kind}:${reference.id}`);
if (!fact || fact.name !== reference.name || fact.parentId !== reference.parentId || fact.evidence !== reference.evidence || fact.sourceVerified !== reference.sourceVerified || fact.systemId !== (reference.kind === 'station' ? reference.parentId : reference.system?.id ?? null) || fact.regionId !== (reference.region?.id ?? null)) throw new Error('Public location facts do not match enrichment pin');
}
if (reference.system !== null) visitReference(reference.system);
if (reference.region !== null) visitReference(reference.region);
};
for (const row of rows) {
if (row.characterId !== characterId) throw new Error('Location row character mismatch');
visitReference(row.location);
const nameRequested = namesRequested.has(row.itemId);
const locationRequested = locationsRequested.has(row.itemId);
const validateHelperStatus = (status: string, requested: boolean, resolved: boolean, missing: boolean, state: string, field: string): void => {
if (!requested) { if (status !== 'not_requested') throw new Error(`${field} row is not requested`); return; }
if (missing) {
if (status !== 'missing') throw new Error(`${field} row status disagrees with missing pin`);
} else if (resolved) {
if (status === 'not_requested' || status === 'forbidden' || status === 'unavailable') throw new Error(`${field} row status disagrees with resolved pin`);
} else if (state === 'forbidden' && status !== 'forbidden') throw new Error(`${field} row is not explicitly forbidden`);
else if (state === 'unavailable' && status !== 'unavailable') throw new Error(`${field} row is not explicitly unavailable`);
else if (state === 'observed') throw new Error(`${field} row is absent from observed pin`);
};
validateHelperStatus(row.nameStatus, nameRequested, namesResolved.has(row.itemId), namesMissing.has(row.itemId), namesBatchStates.get(row.itemId) ?? pin.names.state, 'name');
validateHelperStatus(row.locationStatus, locationRequested, locationsResolved.has(row.itemId), locationsMissing.has(row.itemId), locationsBatchStates.get(row.itemId) ?? pin.locations.state, 'location');
if (row.nameStatus === 'resolved' && !namesResolved.has(row.itemId)) throw new Error('Resolved name is absent from enrichment pin');
if (row.nameStatus === 'resolved' && typeof row.customName !== 'string') throw new Error('Resolved name has no custom name');
if (row.nameStatus !== 'resolved' && row.customName !== null) throw new Error('Unresolved name has a custom name');
if (row.nameStatus === 'not_requested' && nameRequested) throw new Error('Unrequested name row disagrees with enrichment pin');
if (row.nameStatus !== 'not_requested' && !nameRequested) throw new Error('Requested name row is absent from enrichment pin');
if (row.locationStatus === 'resolved' && !locationsResolved.has(row.itemId)) throw new Error('Resolved position is absent from enrichment pin');
if (row.locationStatus === 'resolved' && row.position === null) throw new Error('Resolved position is missing coordinates');
if (row.locationStatus !== 'resolved' && row.position !== null) throw new Error('Unresolved location has coordinates');
if (row.locationStatus === 'not_requested' && locationRequested) throw new Error('Unrequested location row disagrees with enrichment pin');
if (row.locationStatus !== 'not_requested' && !locationRequested) throw new Error('Requested location row is absent from enrichment pin');
const unresolvedReasons = new Set<string>(row.unresolved);
if (row.nameStatus === 'expired' && !unresolvedReasons.has('name_expired')) throw new Error('Expired name has no explanation');
if (row.nameStatus === 'forbidden' && !unresolvedReasons.has('name_forbidden')) throw new Error('Forbidden name has no explanation');
if (row.nameStatus === 'unavailable' && !unresolvedReasons.has('name_unavailable')) throw new Error('Unavailable name has no explanation');
if (row.locationStatus === 'expired' && !unresolvedReasons.has('location_expired')) throw new Error('Expired location has no explanation');
if (row.locationStatus === 'forbidden' && !unresolvedReasons.has('location_forbidden')) throw new Error('Forbidden location has no explanation');
if (row.locationStatus === 'unavailable' && !unresolvedReasons.has('location_unavailable')) throw new Error('Unavailable location has no explanation');
if (row.unresolved.length > 0) unresolved.add(row.itemId);
}
return unresolved.size;
}
function validJobSnapshotTimes(syncedAt: number, expiresAt: number, timestamp: number): boolean {
return Number.isFinite(syncedAt) && syncedAt >= 0 && syncedAt <= timestamp && Number.isFinite(expiresAt) && expiresAt > timestamp && expiresAt > syncedAt && expiresAt - syncedAt <= SNAPSHOT_MAX_LIFETIME_MS;
}
export const bridgeCommitSyncJob = mutationGeneric({
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), chunkCount: v.number(), syncedAt: v.number(), expiresAt: v.number() },
args: {
...workerBridgeArgs,
jobId: v.string(),
leaseToken: v.string(),
chunkCount: v.number(),
syncedAt: v.number(),
expiresAt: v.number(),
enrichmentStatus: v.optional(v.union(v.literal('complete'), v.literal('partial'), v.literal('failed'), v.literal('unavailable'))),
enrichmentVersion: v.optional(v.string()),
enrichmentInputPin: v.optional(v.any()),
enrichmentChunkCount: v.optional(v.number()),
enrichmentUnresolvedCount: v.optional(v.number()),
enrichmentError: v.optional(v.string()),
},
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
const job = await findJob(ctx, args.jobId);
@@ -1386,8 +1687,57 @@ export const bridgeCommitSyncJob = mutationGeneric({
if (!Number.isInteger(args.chunkCount) || args.chunkCount < 0 || args.chunkCount > MAX_CHUNKS || !validJobSnapshotTimes(args.syncedAt, args.expiresAt, now())) throw new Error('Invalid snapshot');
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
if (!generation || generation.status !== 'staging' || (generation.stagedChunkCount ?? 0) !== args.chunkCount) throw new Error('Snapshot is incomplete');
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion) throw new Error('Synchronization authorization is stale');
await ctx.db.patch(generation._id, { status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt });
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion || character.authorizationStatus !== 'active') throw new Error('Synchronization authorization is stale');
const enrichmentStatus = args.enrichmentStatus;
const stagedEnrichmentChunks = generation.enrichmentChunkCount ?? 0;
if (enrichmentStatus === undefined) {
if (stagedEnrichmentChunks !== 0 || generation.enrichmentStatus !== undefined) throw new Error('Location enrichment manifest is missing');
} else {
const expectedEnrichmentChunks = args.enrichmentChunkCount ?? stagedEnrichmentChunks;
if (!Number.isInteger(expectedEnrichmentChunks) || expectedEnrichmentChunks < 0 || expectedEnrichmentChunks > MAX_CHUNKS || expectedEnrichmentChunks !== stagedEnrichmentChunks) throw new Error('Location enrichment is incomplete');
const authority = { userId: leased.userId, principal: character.ownerHash, characterId: leased.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion };
let pin: any;
if (enrichmentStatus !== 'failed' && enrichmentStatus !== 'unavailable') {
if (typeof args.enrichmentVersion !== 'string' || !args.enrichmentInputPin || typeof args.enrichmentInputPin !== 'object') throw new Error('Location enrichment version and pin are required');
boundedJsonBytes(args.enrichmentInputPin, MAX_ENRICHMENT_PIN_BYTES, 'Location enrichment pin');
try { validateReferenceEnrichmentPin(args.enrichmentInputPin, { assetGeneration: leased.generation, authority }); } catch { throw new Error('Location enrichment pin is invalid'); }
pin = args.enrichmentInputPin as any;
if (args.enrichmentVersion !== deterministicEnrichmentVersion(pin)) throw new Error('Location enrichment version is not deterministic');
}
if (enrichmentStatus === 'failed' || enrichmentStatus === 'unavailable') {
const stagedAssetRows = generation.stagedAssetRowCount ?? 0;
if (stagedEnrichmentChunks !== 0 || (generation.enrichmentRowCount ?? 0) !== 0 || (generation.enrichmentByteCount ?? 0) !== 0 || args.enrichmentInputPin !== undefined || args.enrichmentVersion !== undefined || args.enrichmentUnresolvedCount !== stagedAssetRows || typeof args.enrichmentError !== 'string' || args.enrichmentError.length === 0) throw new Error('Failed enrichment cannot publish rows or a complete pin');
} else {
const facts = await stagedAssetFacts(ctx, leased, generation);
const staged = await stagedLocationRows(ctx, leased, generation, facts, authority);
if (staged.rows.length !== facts.size || staged.ids.size !== facts.size || (generation.enrichmentRowCount ?? 0) !== facts.size) throw new Error('Location enrichment does not cover the staged asset generation');
assertCanonicalContainment(staged.rows, facts, leased.characterId);
if (enrichmentStatus === 'complete') {
const assetIds = new Set(facts.keys());
for (const part of [pin.names, pin.locations]) {
if (part.state === 'not_requested' || part.requestedIds.length !== assetIds.size || new Set(part.requestedIds).size !== assetIds.size || part.requestedIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin does not cover the staged asset generation');
if (new Set([...part.resolvedIds, ...part.missingIds]).size !== assetIds.size || part.resolvedIds.some((id: string) => !assetIds.has(id)) || part.missingIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin coverage is incomplete');
}
}
const unresolvedCount = validateLocationRowPinAgreement(staged.rows, pin, leased.characterId);
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || args.enrichmentUnresolvedCount !== unresolvedCount) throw new Error('Location enrichment unresolved count is not truthful');
if (enrichmentStatus === 'complete' && unresolvedCount !== 0) throw new Error('Complete enrichment contains unresolved rows');
if (enrichmentStatus === 'partial' && unresolvedCount === 0 && pin.names.state === 'observed' && pin.locations.state === 'observed') throw new Error('Partial enrichment has no unresolved coverage');
}
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || (args.enrichmentUnresolvedCount ?? 0) < 0) throw new Error('Invalid enrichment unresolved count');
if (args.enrichmentError !== undefined && (typeof args.enrichmentError !== 'string' || args.enrichmentError.length > MAX_ERROR_LENGTH)) throw new Error('Invalid enrichment error');
}
await ctx.db.patch(generation._id, {
status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt,
...(enrichmentStatus === undefined ? {} : {
enrichmentStatus,
...(args.enrichmentVersion === undefined ? {} : { enrichmentVersion: args.enrichmentVersion }),
...(args.enrichmentInputPin === undefined ? {} : { enrichmentInputPin: args.enrichmentInputPin }),
enrichmentChunkCount: args.enrichmentChunkCount ?? (generation.enrichmentChunkCount ?? 0),
enrichmentUnresolvedCount: args.enrichmentUnresolvedCount ?? 0,
...(args.enrichmentError === undefined ? {} : { enrichmentError: args.enrichmentError.slice(0, MAX_ERROR_LENGTH) }),
}),
});
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'ready', generation: leased.generation, syncedAt: args.syncedAt, error: undefined } : entry);
await ctx.db.patch(character._id, { state: 'ready', datasetStatuses: statuses, syncedAt: args.syncedAt, expiresAt: args.expiresAt, activeGeneration: leased.generation, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: undefined });
await ctx.db.patch(leased._id, { status: 'succeeded', updatedAt: now(), finishedAt: now(), leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, cancelRequested: undefined, lastError: undefined });
@@ -1456,6 +1806,23 @@ export const bridgeCooldown = queryGeneric({
},
});
export const bridgeRecordSyncCooldown = mutationGeneric({
args: { ...workerBridgeArgs, context: v.string(), until: v.number(), group: v.optional(v.string()) },
handler: async (ctx, args) => {
assertSyncWorker(args.workerSecret);
if (!args.context || args.context.length > 256 || !Number.isFinite(args.until) || args.until < now() || args.until > now() + 24 * 60 * 60 * 1000 || (args.group !== undefined && (args.group.length === 0 || args.group.length > 128))) throw new Error('Invalid synchronization cooldown');
const timestamp = now();
const contexts = [args.context, ...(args.group ? [`group:${args.group}`] : [])];
for (const context of contexts) {
const existing = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first();
const until = Math.max(existing?.until ?? 0, args.until);
if (existing) await ctx.db.patch(existing._id, { until, group: args.group, kind: 'server', updatedAt: timestamp });
else await ctx.db.insert('syncCooldowns', { context, until, group: args.group, kind: 'server', updatedAt: timestamp });
}
return true;
},
});
export const bridgeSyncWorkerCooldown = queryGeneric({
args: { ...workerBridgeArgs, context: v.string(), group: v.optional(v.string()) },
handler: async (ctx, args) => {
+41
View File
@@ -42,6 +42,14 @@ export const datasetStatusValidator = v.object({
error: v.optional(v.string()),
});
export const enrichmentStatusValidator = v.union(
v.literal("staging"),
v.literal("complete"),
v.literal("partial"),
v.literal("failed"),
v.literal("unavailable"),
);
export const assetValidator = v.object({
itemId: v.string(),
characterId: v.number(),
@@ -136,6 +144,7 @@ export default defineSchema({
// Optional keeps already-deployed completed generations readable; new
// staging generations always initialize and maintain this durable counter.
stagedChunkCount: v.optional(v.number()),
stagedAssetRowCount: v.optional(v.number()),
chunkCount: v.optional(v.number()),
syncedAt: v.optional(v.number()),
expiresAt: v.optional(v.number()),
@@ -143,6 +152,15 @@ export default defineSchema({
grantRevision: v.number(),
authorizationEpoch: v.number(),
credentialVersion: v.number(),
/** Optional enrichment is tied to this exact immutable asset generation. */
enrichmentStatus: v.optional(enrichmentStatusValidator),
enrichmentVersion: v.optional(v.string()),
enrichmentInputPin: v.optional(v.any()),
enrichmentChunkCount: v.optional(v.number()),
enrichmentRowCount: v.optional(v.number()),
enrichmentByteCount: v.optional(v.number()),
enrichmentUnresolvedCount: v.optional(v.number()),
enrichmentError: v.optional(v.string()),
})
.index("by_user_character_generation", ["userId", "characterId", "generation"])
.index("by_generation", ["generation"]),
@@ -155,6 +173,29 @@ export default defineSchema({
assets: v.array(assetValidator),
}).index("by_snapshot_index", ["userId", "characterId", "generation", "index"]),
/** Compact per-item facts make enrichment staging independent of a full
* generation collect. They are scoped to the immutable generation. */
assetFacts: defineTable({
userId: v.string(), characterId: v.number(), generation: v.string(), itemId: v.string(),
typeId: v.number(), quantity: v.number(), locationId: v.string(), locationType: v.string(), locationFlag: v.string(),
isSingleton: v.optional(v.boolean()), isBlueprintCopy: v.optional(v.boolean()),
}).index("by_snapshot_item", ["userId", "characterId", "generation", "itemId"]),
/** One indexed identity per persisted enrichment row enforces global
* uniqueness without rereading every prior location chunk. */
locationEnrichmentItems: defineTable({
userId: v.string(), characterId: v.number(), generation: v.string(), itemId: v.string(),
}).index("by_snapshot_item", ["userId", "characterId", "generation", "itemId"]),
/** Private location rows are never stored in the public/reference catalog. */
locationEnrichmentChunks: defineTable({
userId: v.string(),
characterId: v.number(),
generation: v.string(),
index: v.number(),
rows: v.array(v.any()),
}).index("by_snapshot_index", ["userId", "characterId", "generation", "index"]),
interactions: defineTable({
userId: v.string(),
requestId: v.string(),
+7 -1
View File
@@ -26,11 +26,14 @@ export const CAPABILITY_REQUIREMENTS: Readonly<Record<Capability, readonly Requi
asset_valuation: ['assets', 'market_data'],
manufacturing_opportunities: ['assets', 'blueprints', 'skills', 'market_data'],
inventory_query: ['assets', 'reference_data'],
/** Explicit opt-in for character-scoped private structure evidence. */
inventory_locations: ['assets', 'reference_data'],
wallet_balance: ['wallet_balance'],
};
/** Assets scope verified against the 2026-08-18 ESI OpenAPI contract. */
export const ESI_ASSETS_SCOPE = 'esi-assets.read_assets.v1';
export const ESI_STRUCTURES_SCOPE = 'esi-universe.read_structures.v1';
export const ESI_WALLET_SCOPE = 'esi-wallet.read_character_wallet.v1';
export const ESI_BLUEPRINTS_SCOPE = 'esi-characters.read_blueprints.v1';
export const ESI_SKILLS_SCOPE = 'esi-skills.read_skills.v1';
@@ -68,13 +71,16 @@ export const CAPABILITY_DEFINITIONS: Readonly<Record<Capability, CapabilityDefin
inventory_query: {
capability: 'inventory_query', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
inventory_locations: {
capability: 'inventory_locations', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
wallet_balance: {
capability: 'wallet_balance', datasets: ['wallet_balance'], requiredScopes: [ESI_WALLET_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
};
export const SERVER_SCOPE_ALLOWLIST: readonly string[] = [
ESI_ASSETS_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE,
ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE,
];
const SYSTEM_JOB_DATASETS: Readonly<Record<'reference' | 'public_feed' | 'maintenance', readonly DatasetKind[]>> = {
reference: ['reference_data'],
+909
View File
@@ -0,0 +1,909 @@
import type { Asset, DecimalId, ReferenceVersion } from './model';
export type { DecimalId } from './model';
/** The signed-int64 spelling used by ESI for character-scoped item IDs. */
export const MAX_DECIMAL_ID = 9_223_372_036_854_775_807n;
export const DEFAULT_CONTAINMENT_DEPTH = 64;
export const MAX_CONTAINMENT_ITEMS = 50_000;
/** Matches the reviewed T06 helper request bound. */
export const MAX_ENRICHMENT_IDS = 1_000;
/** Location rows include breadcrumbs/reference facts; keep persistence chunks smaller than asset chunks. */
export const MAX_LOCATION_ENRICHMENT_CHUNK = 100;
export const MAX_ENRICHMENT_NAME_LENGTH = 512;
export type InventoryLocationAuthority = {
userId: string;
principal: string;
characterId: number;
scopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
};
export type EnrichmentObservationState = 'observed' | 'not_requested' | 'forbidden' | 'unavailable';
export type EnrichmentNameStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
export type EnrichmentLocationStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
export type NamesObservation = {
requestedIds: readonly DecimalId[];
names: readonly { itemId: DecimalId; name: string }[];
missing: readonly DecimalId[];
retrievedAt: number;
expiresAt: number;
complete: boolean;
/** Set by an adapter when the request itself was denied or could not run. */
state?: EnrichmentObservationState;
characterId?: number;
};
export type LocationsObservation = {
requestedIds: readonly DecimalId[];
locations: readonly { itemId: DecimalId; position: { x: number; y: number; z: number } }[];
missing: readonly DecimalId[];
retrievedAt: number;
expiresAt: number;
complete: boolean;
state?: EnrichmentObservationState;
characterId?: number;
};
/** Each ESI helper call remains capped at MAX_ENRICHMENT_IDS; a generation may pin bounded batches. */
export type NamesObservationBatch = NamesObservation;
export type LocationsObservationBatch = LocationsObservation;
export type NameUnavailableReason = 'name_missing' | 'name_expired' | 'name_forbidden' | 'name_unavailable';
export type LocationUnavailableReason = 'location_missing' | 'location_expired' | 'location_forbidden' | 'location_unavailable';
export type ContainmentUnresolvedReason =
| 'invalid_item_id'
| 'invalid_location_id'
| 'duplicate_item_id'
| 'missing_parent'
| 'parent_unresolved'
| 'cycle'
| 'depth_cap'
| 'invalid_asset'
| 'cap_exceeded';
export type ReferenceUnresolvedReason =
| 'reference_unknown'
| 'reference_unavailable'
| 'unsafe_reference_id'
| 'forbidden_structure'
| 'unavailable_structure';
export type UnresolvedReason = ContainmentUnresolvedReason | NameUnavailableReason | LocationUnavailableReason | ReferenceUnresolvedReason;
export type FittedClassification = 'fitted' | 'not_fitted' | 'unknown';
export type FittedClassificationDetail = {
classification: FittedClassification;
/** The source flag is retained even when the classification is intentionally unknown. */
rawFlag: string;
reason: 'slot_flag' | 'ordinary_location_flag' | 'ambiguous_autofit' | 'ambiguous_special_bay';
};
/**
* Flags with an unambiguous fitting meaning. AutoFit and all specialized bays
* deliberately remain unknown: ESI does not say whether they contain a fit.
*/
const FITTED_SLOT_FLAG = /^(?:Hi|Med|Lo|Rig|SubSystem)Slot[0-7]$/;
const AMBIGUOUS_FIT_FLAG = new Set([
'AutoFit', 'BoosterBay', 'Cargo', 'DroneBay', 'FighterBay', 'FighterTube0', 'FighterTube1', 'FighterTube2', 'FighterTube3', 'FighterTube4',
'ExpeditionHold', 'FleetHangar', 'FrigateEscapeBay', 'Hangar', 'HangarAll', 'MoonMaterialBay', 'QuafeBay', 'ShipHangar', 'SubSystemBay',
'SpecializedAmmoHold', 'SpecializedAsteroidHold', 'SpecializedCommandCenterHold', 'SpecializedFuelBay', 'SpecializedGasHold',
'SpecializedIceHold', 'SpecializedIndustrialShipHold', 'SpecializedLargeShipHold', 'SpecializedMaterialBay', 'SpecializedMediumShipHold',
'SpecializedMineralHold', 'SpecializedOreHold', 'SpecializedPlanetaryCommoditiesHold', 'SpecializedSalvageHold', 'SpecializedShipHold',
'SpecializedSmallShipHold', 'StructureDeedBay', 'Wardrobe',
]);
export function classifyFittedFlag(rawFlag: unknown): FittedClassificationDetail {
const flag = typeof rawFlag === 'string' ? rawFlag : '';
if (FITTED_SLOT_FLAG.test(flag)) return { classification: 'fitted', rawFlag: flag, reason: 'slot_flag' };
if (flag === 'AutoFit') return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_autofit' };
if (AMBIGUOUS_FIT_FLAG.has(flag)) return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_special_bay' };
return { classification: 'not_fitted', rawFlag: flag, reason: 'ordinary_location_flag' };
}
export type ContainmentRoot =
| { kind: 'external'; id: DecimalId; locationType: string; locationFlag: string }
| { kind: 'item'; id: DecimalId; locationType: 'item'; locationFlag: string }
| { kind: 'unresolved'; id: DecimalId | null; locationType: string; locationFlag: string };
export type Breadcrumb = {
itemId: DecimalId;
typeId: number;
/** Null is intentional; no synthetic "Item <id>" names are generated. */
name: string | null;
};
export type ContainmentNode = {
itemId: DecimalId;
characterId: number;
typeId: number;
quantity: number;
locationId: DecimalId;
locationType: string;
/** Exact source value, including AutoFit/special-bay values. */
locationFlag: string;
isSingleton?: boolean;
isBlueprintCopy?: boolean;
parentItemId: DecimalId | null;
root: ContainmentRoot;
depth: number | null;
breadcrumbs: readonly Breadcrumb[];
fitted: FittedClassificationDetail;
/** Compatibility projections: ambiguous flags are null/unknown, never false by guess. */
fittedClassification: FittedClassification;
isFitted: boolean | null;
unresolved: readonly ContainmentUnresolvedReason[];
};
export type ContainmentInput = {
assets: readonly Asset[];
characterId?: number;
names?: ReadonlyMap<DecimalId, string> | Readonly<Record<string, string>>;
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
maxDepth?: number;
};
export type ContainmentResult = {
nodes: readonly ContainmentNode[];
/** Root IDs are sorted decimal strings and have no fabricated labels. */
roots: readonly ContainmentRoot[];
unresolved: readonly { itemId: DecimalId | null; reasons: readonly ContainmentUnresolvedReason[] }[];
complete: boolean;
maxDepth: number;
};
export type ReferenceEnrichmentBatchPin = {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
};
export type ReferenceLocationFactPin = {
kind: 'station' | 'system' | 'region';
id: DecimalId;
name: string | null;
parentId: DecimalId | null;
evidence: 'verified' | 'system_listed';
sourceVerified: boolean;
systemId: DecimalId | null;
regionId: DecimalId | null;
referenceVersion: ReferenceVersion;
};
export type StructureObservationPin = {
structureId: DecimalId;
characterId: number;
status: 'accessible' | 'forbidden' | 'unavailable';
evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable';
name: string | null;
solarSystemId: DecimalId | null;
ownerId: DecimalId | null;
typeId: DecimalId | null;
observedAt: number;
expiresAt: number;
referenceVersion: ReferenceVersion;
};
export type ReferenceEnrichmentPin = {
version: 1;
assetGeneration: string;
authority: InventoryLocationAuthority;
referenceVersion: ReferenceVersion;
containmentDepth: number;
names: {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
batches?: readonly ReferenceEnrichmentBatchPin[];
};
locations: {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
batches?: readonly ReferenceEnrichmentBatchPin[];
};
structures?: readonly StructureObservationPin[];
/** Digest is an internal consistency check; pinned referenceVersion supplies provenance. */
referenceFacts?: readonly ReferenceLocationFactPin[];
referenceFactsDigest?: string;
structureRevision: string;
};
export type LocationUnresolved = {
itemId: DecimalId | null;
reasons: readonly UnresolvedReason[];
};
export type LocationReference = {
kind: 'station' | 'system' | 'region' | 'structure' | 'unknown';
id: DecimalId;
name: string | null;
parentId: DecimalId | null;
/** `verified` and `system_listed` are intentionally not collapsed. */
evidence: 'verified' | 'system_listed' | 'private_structure_access' | 'unknown';
sourceVerified: boolean | null;
referenceVersion: ReferenceVersion;
/** Private structure access does not establish docking permission. */
dockingPermission: 'unknown';
/** Present for structure observations; public catalog references are null. */
observedAt?: number | null;
expiresAt?: number | null;
system: LocationReference | null;
region: LocationReference | null;
};
export type EnrichedLocationNode = Omit<ContainmentNode, 'unresolved'> & {
/** Persisted rows carry the complete merged reason set, not only graph reasons. */
unresolved: readonly UnresolvedReason[];
customName: string | null;
nameStatus: EnrichmentNameStatus;
position: { x: number; y: number; z: number } | null;
locationStatus: EnrichmentLocationStatus;
location: LocationReference;
};
export type LocationEnrichmentResult = {
status: 'complete' | 'partial';
complete: boolean;
items: readonly EnrichedLocationNode[];
/** Aliases make the seam convenient for sync and later projection code. */
rows: readonly EnrichedLocationNode[];
roots: readonly LocationReference[];
unresolved: readonly LocationUnresolved[];
inputPin: ReferenceEnrichmentPin;
enrichmentVersion: string;
};
export type PersistedLocationValidationOptions = {
authority?: InventoryLocationAuthority;
};
/** Runtime guard used at the Convex worker boundary before private rows are persisted. */
export function validatePersistedLocationRows(value: unknown, options?: PersistedLocationValidationOptions): asserts value is readonly EnrichedLocationNode[] {
if (!Array.isArray(value) || value.length > MAX_LOCATION_ENRICHMENT_CHUNK) throw new TypeError('location enrichment chunk is oversized');
const allowed = new Set(['itemId', 'characterId', 'typeId', 'quantity', 'locationId', 'locationType', 'locationFlag', 'isSingleton', 'isBlueprintCopy', 'parentItemId', 'root', 'depth', 'breadcrumbs', 'fitted', 'fittedClassification', 'isFitted', 'unresolved', 'customName', 'nameStatus', 'position', 'locationStatus', 'location']);
const statuses = new Set(['resolved', 'missing', 'expired', 'forbidden', 'unavailable', 'not_requested']);
const reasons = new Set(['invalid_item_id', 'invalid_location_id', 'duplicate_item_id', 'missing_parent', 'parent_unresolved', 'cycle', 'depth_cap', 'invalid_asset', 'cap_exceeded', 'name_missing', 'name_expired', 'name_forbidden', 'name_unavailable', 'location_missing', 'location_expired', 'location_forbidden', 'location_unavailable', 'reference_unknown', 'reference_unavailable', 'unsafe_reference_id', 'forbidden_structure', 'unavailable_structure']);
const allowedLocation = new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'referenceVersion', 'dockingPermission', 'observedAt', 'expiresAt', 'system', 'region']);
const allowedRoot = new Set(['kind', 'id', 'locationType', 'locationFlag']);
const allowedBreadcrumb = new Set(['itemId', 'typeId', 'name']);
const allowedFitted = new Set(['classification', 'rawFlag', 'reason']);
const text = (input: unknown, field: string, max: number): string => {
if (typeof input !== 'string' || input.length === 0 || input.length > max) throw new TypeError(`${field} is invalid`);
return input;
};
const id = (input: unknown, field: string): DecimalId => { assertDecimalId(input, field); return input; };
const location = (input: unknown, depth: number): void => {
if (depth > 3 || !input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('location evidence is invalid');
const candidate = input as Partial<LocationReference>;
if (Object.keys(candidate).some((key) => !allowedLocation.has(key))) throw new TypeError('location evidence has unknown fields');
if (!['station', 'system', 'region', 'structure', 'unknown'].includes(candidate.kind ?? '')) throw new TypeError('location evidence kind is invalid');
id(candidate.id, 'location evidence ID');
if (candidate.name !== null && candidate.name !== undefined) text(candidate.name, 'location evidence name', MAX_ENRICHMENT_NAME_LENGTH);
if (candidate.parentId !== null && candidate.parentId !== undefined) id(candidate.parentId, 'location evidence parent ID');
if (!['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(candidate.evidence ?? '')) throw new TypeError('location evidence provenance is invalid');
const privateEvidence = candidate.evidence === 'private_structure_access';
if (privateEvidence) {
if (!options?.authority?.scopes.includes('esi-universe.read_structures.v1')) throw new TypeError('private structure evidence requires pinned structure scope');
if (candidate.kind !== 'structure' || candidate.sourceVerified !== true) throw new TypeError('private structure evidence source is invalid');
} else if (candidate.kind === 'structure' && candidate.evidence !== 'unknown') throw new TypeError('structure evidence source is invalid');
if (candidate.sourceVerified !== null && candidate.sourceVerified !== undefined && typeof candidate.sourceVerified !== 'boolean') throw new TypeError('location evidence verification is invalid');
if (candidate.kind === 'station' && ((candidate.evidence === 'verified' && candidate.sourceVerified !== true) || (candidate.evidence === 'system_listed' && candidate.sourceVerified !== false))) throw new TypeError('station evidence source is inconsistent');
if ((candidate.kind === 'system' || candidate.kind === 'region') && (candidate.evidence !== 'verified' || candidate.sourceVerified !== true)) throw new TypeError('public reference evidence source is inconsistent');
if (candidate.kind === 'unknown' && (candidate.evidence !== 'unknown' || candidate.sourceVerified !== null)) throw new TypeError('unknown location evidence source is inconsistent');
if (candidate.kind === 'structure') {
if (candidate.name === undefined || candidate.observedAt === undefined || candidate.expiresAt === undefined || typeof candidate.observedAt !== 'number' || !Number.isSafeInteger(candidate.observedAt) || typeof candidate.expiresAt !== 'number' || !Number.isSafeInteger(candidate.expiresAt) || candidate.observedAt < 0 || candidate.expiresAt < candidate.observedAt || candidate.expiresAt > 8_640_000_000_000_000) throw new TypeError('structure evidence freshness is invalid');
if (candidate.evidence === 'unknown' && (candidate.name !== null || candidate.sourceVerified !== null)) throw new TypeError('denied structure evidence contains private facts');
} else if (candidate.observedAt !== undefined || candidate.expiresAt !== undefined) throw new TypeError('public location evidence contains structure freshness');
if (candidate.system === undefined || candidate.region === undefined) throw new TypeError('location evidence hierarchy is incomplete');
text(candidate.referenceVersion, 'location evidence reference version', 256);
if (candidate.dockingPermission !== 'unknown') throw new TypeError('location evidence docking permission is invalid');
if (candidate.system !== null && candidate.system !== undefined) location(candidate.system, depth + 1);
if (candidate.region !== null && candidate.region !== undefined) location(candidate.region, depth + 1);
};
for (const row of value) {
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new TypeError('location enrichment row is invalid');
const candidate = row as Partial<EnrichedLocationNode>;
if (Object.keys(candidate).some((key) => !allowed.has(key))) throw new TypeError('location enrichment row has unknown fields');
const itemId = id(candidate.itemId, 'location row item ID');
if (typeof candidate.characterId !== 'number' || !Number.isSafeInteger(candidate.characterId) || candidate.characterId <= 0 || typeof candidate.typeId !== 'number' || !Number.isSafeInteger(candidate.typeId) || candidate.typeId <= 0 || typeof candidate.quantity !== 'number' || !Number.isSafeInteger(candidate.quantity) || candidate.quantity < 0) throw new TypeError('location enrichment source facts are invalid');
id(candidate.locationId, 'location row location ID');
if (!['station', 'solar_system', 'item', 'other'].includes(text(candidate.locationType, 'location row type', 32)) || text(candidate.locationFlag, 'location row flag', 128).length > 128) throw new TypeError('location enrichment location facts are invalid');
if (candidate.isSingleton !== undefined && typeof candidate.isSingleton !== 'boolean') throw new TypeError('location row singleton fact is invalid');
if (candidate.isBlueprintCopy !== undefined && typeof candidate.isBlueprintCopy !== 'boolean') throw new TypeError('location row blueprint fact is invalid');
if (candidate.parentItemId !== null && candidate.parentItemId !== undefined) id(candidate.parentItemId, 'location row parent ID');
if (!candidate.root || typeof candidate.root !== 'object' || Array.isArray(candidate.root)) throw new TypeError('location enrichment root is invalid');
const root = candidate.root as Partial<ContainmentRoot>;
if (Object.keys(root).some((key) => !allowedRoot.has(key))) throw new TypeError('location root has unknown fields');
if (!['external', 'item', 'unresolved'].includes(root.kind ?? '')) throw new TypeError('location enrichment root kind is invalid');
if (root.id !== null && root.id !== undefined) id(root.id, 'location root ID');
if (root.kind !== 'unresolved' && (root.id === null || root.id === undefined)) throw new TypeError('location root ID is required');
text(root.locationType, 'location root type', 32); text(root.locationFlag, 'location root flag', 128);
if (candidate.depth !== null && (typeof candidate.depth !== 'number' || !Number.isSafeInteger(candidate.depth) || candidate.depth < 0 || candidate.depth > DEFAULT_CONTAINMENT_DEPTH)) throw new TypeError('location row depth is invalid');
if (!Array.isArray(candidate.breadcrumbs) || candidate.breadcrumbs.length > DEFAULT_CONTAINMENT_DEPTH + 1) throw new TypeError('location breadcrumbs are oversized');
for (const breadcrumb of candidate.breadcrumbs) {
if (!breadcrumb || typeof breadcrumb !== 'object') throw new TypeError('location breadcrumb is invalid');
const value = breadcrumb as Partial<Breadcrumb>;
if (Object.keys(value).some((key) => !allowedBreadcrumb.has(key))) throw new TypeError('location breadcrumb has unknown fields');
id(value.itemId, 'breadcrumb item ID');
if (typeof value.typeId !== 'number' || !Number.isSafeInteger(value.typeId) || value.typeId <= 0) throw new TypeError('breadcrumb type ID is invalid');
if (value.name !== null && value.name !== undefined) text(value.name, 'breadcrumb name', MAX_ENRICHMENT_NAME_LENGTH);
}
if (!candidate.fitted || typeof candidate.fitted !== 'object' || Object.keys(candidate.fitted).some((key) => !allowedFitted.has(key)) || !['fitted', 'not_fitted', 'unknown'].includes(candidate.fitted.classification ?? '') || !['slot_flag', 'ordinary_location_flag', 'ambiguous_autofit', 'ambiguous_special_bay'].includes(candidate.fitted.reason ?? '')) throw new TypeError('fitted evidence is invalid');
text(candidate.fitted.rawFlag, 'fitted raw flag', 128);
if (!['fitted', 'not_fitted', 'unknown'].includes(candidate.fittedClassification ?? '') || (candidate.isFitted !== null && typeof candidate.isFitted !== 'boolean')) throw new TypeError('fitted projection is invalid');
if (!Array.isArray(candidate.unresolved) || candidate.unresolved.length > 24) throw new TypeError('location unresolved reasons are oversized');
if (candidate.unresolved.some((reason: unknown) => typeof reason !== 'string' || !reasons.has(reason))) throw new TypeError('location unresolved reason is invalid');
if (candidate.customName !== null && candidate.customName !== undefined) text(candidate.customName, 'location custom name', MAX_ENRICHMENT_NAME_LENGTH);
if (!statuses.has(candidate.nameStatus ?? '') || !statuses.has(candidate.locationStatus ?? '')) throw new TypeError('location enrichment status is invalid');
if (candidate.position !== null && candidate.position !== undefined && (!candidate.position || ![candidate.position.x, candidate.position.y, candidate.position.z].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate) && Math.abs(coordinate) <= 1e30))) throw new TypeError('location enrichment position is invalid');
location(candidate.location, 0);
void itemId;
}
}
export function isDecimalId(value: unknown): value is DecimalId {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return false;
try { return BigInt(value) <= MAX_DECIMAL_ID; } catch { return false; }
}
export function assertDecimalId(value: unknown, field = 'ID'): asserts value is DecimalId {
if (!isDecimalId(value)) throw new TypeError(`${field} must be a positive signed-int64 decimal string`);
}
export function canonicalDecimalIds(values: readonly unknown[], field = 'IDs'): DecimalId[] {
if (!Array.isArray(values)) throw new TypeError(`${field} must be an array`);
const output = values.map((value, index) => {
assertDecimalId(value, `${field}[${index}]`);
return value;
});
return [...new Set(output)].sort(compareDecimalIds);
}
export function compareDecimalIds(left: string, right: string): number {
const leftTrimmed = left.replace(/^0+(?=\d)/, '');
const rightTrimmed = right.replace(/^0+(?=\d)/, '');
return leftTrimmed.length - rightTrimmed.length || (leftTrimmed < rightTrimmed ? -1 : leftTrimmed > rightTrimmed ? 1 : 0);
}
export function decimalIdToSafeNumber(id: DecimalId): number | null {
assertDecimalId(id);
const value = BigInt(id);
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
}
function getMapValue<T>(map: ReadonlyMap<string, T> | Readonly<Record<string, T>> | undefined, key: string): T | undefined {
if (!map) return undefined;
if (map instanceof Map) return map.get(key);
return (map as Readonly<Record<string, T>>)[key];
}
function getTypeName(map: ContainmentInput['typeNames'], key: number): string | null {
if (!map) return null;
const value = map instanceof Map ? map.get(key) : (map as Readonly<Record<string, string>>)[String(key)];
return typeof value === 'string' && value.trim() ? value : null;
}
function safeCharacterId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
export function validateAuthorityContext(authority: unknown): InventoryLocationAuthority {
if (!authority || typeof authority !== 'object') throw new TypeError('authority context is required');
const candidate = authority as Partial<InventoryLocationAuthority>;
if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.principal !== 'string' || !candidate.principal || candidate.principal.length > 256) throw new TypeError('authority identity is invalid');
if (!safeCharacterId(candidate.characterId)) throw new TypeError('authority character ID is invalid');
const characterId = candidate.characterId;
if (!safeCharacterId(characterId)) throw new TypeError('authority character ID is invalid');
const scopes = candidate.scopes;
if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope) => typeof scope !== 'string' || !scope || scope.length > 128)) throw new TypeError('authority scopes are invalid');
const grantRevision = candidate.grantRevision;
const authorizationEpoch = candidate.authorizationEpoch;
const credentialVersion = candidate.credentialVersion;
if (!safeCharacterId(grantRevision) || !safeCharacterId(authorizationEpoch) || !safeCharacterId(credentialVersion)) throw new TypeError('authority counters are invalid');
return {
userId: candidate.userId,
principal: candidate.principal,
characterId,
scopes: [...new Set(scopes)].sort(),
grantRevision,
authorizationEpoch,
credentialVersion,
};
}
function validateAsset(asset: unknown, characterId: number): asset is Asset {
if (!asset || typeof asset !== 'object') return false;
const candidate = asset as Partial<Asset>;
return isDecimalId(candidate.itemId) && isDecimalId(candidate.locationId) && candidate.characterId === characterId && safeCharacterId(candidate.characterId) &&
typeof candidate.typeId === 'number' && Number.isSafeInteger(candidate.typeId) && candidate.typeId > 0 &&
typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0 &&
typeof candidate.locationType === 'string' && typeof candidate.locationFlag === 'string' &&
(candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean') &&
(candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean');
}
/** Resolve only the local parent graph. It never infers a parent from a numeric ID unless ESI says location_type=item. */
export function resolveContainment(input: ContainmentInput): ContainmentResult {
if (!input || !Array.isArray(input.assets)) throw new TypeError('assets must be an array');
const characterId = input.characterId ?? (input.assets[0] && typeof input.assets[0] === 'object' ? (input.assets[0] as Partial<Asset>).characterId : undefined);
if (!safeCharacterId(characterId)) throw new TypeError('containment character ID is invalid');
const maxDepth = input.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
if (input.assets.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`containment item cap exceeded (${MAX_CONTAINMENT_ITEMS})`);
const duplicateIds = new Set<DecimalId>();
const byId = new Map<DecimalId, Asset>();
for (const asset of input.assets) {
if (!validateAsset(asset, characterId)) continue;
if (byId.has(asset.itemId)) duplicateIds.add(asset.itemId);
else byId.set(asset.itemId, asset);
}
type Computed = { parentItemId: DecimalId | null; root: ContainmentRoot; depth: number | null; breadcrumbs: Breadcrumb[]; reasons: Set<ContainmentUnresolvedReason> };
type Frame = { id: DecimalId; asset: Asset };
const computed = new Map<DecimalId, Computed>();
const names = input.names;
// A walk is at most maxDepth + 2 frames. The explicit budget makes the
// bounded-work guarantee visible even for a maximum-sized snapshot.
const maxWork = MAX_CONTAINMENT_ITEMS * (DEFAULT_CONTAINMENT_DEPTH + 2);
let work = 0;
const unresolvedRoot = (asset: Asset): ContainmentRoot => ({ kind: 'unresolved', id: asset.locationType === 'item' ? asset.locationId : null, locationType: asset.locationType, locationFlag: asset.locationFlag });
const setTerminal = (frame: Frame, reason: ContainmentUnresolvedReason): void => {
computed.set(frame.id, {
parentItemId: frame.asset.locationType === 'item' ? frame.asset.locationId : null,
root: unresolvedRoot(frame.asset), depth: null, breadcrumbs: [], reasons: new Set([reason]),
});
};
const resolveFrom = (start: DecimalId): void => {
if (computed.has(start)) return;
const path: Frame[] = [];
const pathIndex = new Map<DecimalId, number>();
let current = start;
while (!computed.has(current)) {
if (++work > maxWork) {
const frame = path[path.length - 1] ?? { id: start, asset: byId.get(start)! };
setTerminal(frame, 'cap_exceeded');
break;
}
const cycleAt = pathIndex.get(current);
if (cycleAt !== undefined) {
for (const frame of path.slice(cycleAt)) setTerminal(frame, 'cycle');
break;
}
const asset = byId.get(current);
if (!asset) break;
pathIndex.set(current, path.length);
const frame = { id: current, asset };
path.push(frame);
if (asset.locationType !== 'item') {
computed.set(current, { parentItemId: null, root: { kind: 'external', id: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag }, depth: 0, breadcrumbs: [], reasons: new Set() });
break;
}
const parent = byId.get(asset.locationId);
if (!parent) { setTerminal(frame, 'missing_parent'); break; }
if (duplicateIds.has(asset.locationId)) { setTerminal(frame, 'parent_unresolved'); break; }
// One extra parent frame lets a node exactly at maxDepth resolve; a
// longer chain is deterministically unresolved without walking to its root.
if (path.length > maxDepth + 1) { setTerminal(frame, 'depth_cap'); break; }
current = asset.locationId;
}
for (let index = path.length - 1; index >= 0; index -= 1) {
const frame = path[index]!;
if (computed.has(frame.id)) continue;
const asset = frame.asset;
if (asset.locationType !== 'item') continue;
const parent = byId.get(asset.locationId);
const parentResult = parent ? computed.get(parent.itemId) : undefined;
const reasons = new Set<ContainmentUnresolvedReason>();
if (!parent || !parentResult) {
setTerminal(frame, 'parent_unresolved');
continue;
}
const depth = parentResult.depth === null ? null : parentResult.depth + 1;
const breadcrumbs = [...parentResult.breadcrumbs, { itemId: parent.itemId, typeId: parent.typeId, name: getMapValue(names, parent.itemId) ?? getTypeName(input.typeNames, parent.typeId) }];
for (const reason of parentResult.reasons) reasons.add(reason === 'missing_parent' ? 'parent_unresolved' : reason);
let root: ContainmentRoot = parentResult.root.kind === 'item' || parentResult.root.kind === 'external' ? parentResult.root : unresolvedRoot(asset);
let finalDepth = depth;
if (depth === null || depth > maxDepth) { reasons.add(depth === null ? 'parent_unresolved' : 'depth_cap'); finalDepth = null; root = unresolvedRoot(asset); }
computed.set(frame.id, { parentItemId: asset.locationId, root, depth: finalDepth, breadcrumbs, reasons });
}
};
for (const id of [...byId.keys()].sort(compareDecimalIds)) if (!duplicateIds.has(id)) resolveFrom(id);
const nodes: ContainmentNode[] = input.assets.map((source) => {
const record = source !== null && typeof source === 'object' ? source as Partial<Asset> : {};
const valid = validateAsset(source, characterId);
const rawItemId = typeof record.itemId === 'string' ? record.itemId : '';
const rawLocationId = typeof record.locationId === 'string' ? record.locationId : '';
const rawLocationType = typeof record.locationType === 'string' ? record.locationType : 'unknown';
const rawLocationFlag = typeof record.locationFlag === 'string' ? record.locationFlag : '';
const detail = valid ? computed.get(record.itemId!) : undefined;
const reasons = new Set<ContainmentUnresolvedReason>(detail?.reasons ?? []);
if (!isDecimalId(rawItemId)) reasons.add('invalid_item_id');
if (!isDecimalId(rawLocationId)) reasons.add('invalid_location_id');
if (!valid) reasons.add('invalid_asset');
if (valid && duplicateIds.has(record.itemId!)) reasons.add('duplicate_item_id');
const root = detail?.root ?? { kind: 'unresolved' as const, id: isDecimalId(rawLocationId) ? rawLocationId : null, locationType: rawLocationType, locationFlag: rawLocationFlag };
const fitted = classifyFittedFlag(rawLocationFlag);
const base = valid ? source : {
characterId: safeCharacterId(record.characterId) ? record.characterId : characterId,
typeId: typeof record.typeId === 'number' && Number.isSafeInteger(record.typeId) && record.typeId > 0 ? record.typeId : 0,
quantity: typeof record.quantity === 'number' && Number.isSafeInteger(record.quantity) && record.quantity >= 0 ? record.quantity : 0,
locationType: rawLocationType,
locationFlag: rawLocationFlag,
...(typeof record.isSingleton === 'boolean' ? { isSingleton: record.isSingleton } : {}),
...(typeof record.isBlueprintCopy === 'boolean' ? { isBlueprintCopy: record.isBlueprintCopy } : {}),
};
return {
...base,
itemId: isDecimalId(rawItemId) ? rawItemId : '', locationId: isDecimalId(rawLocationId) ? rawLocationId : '',
parentItemId: detail?.parentItemId ?? null, root, depth: detail?.depth ?? null, breadcrumbs: detail?.breadcrumbs ?? [], fitted,
fittedClassification: fitted.classification, isFitted: fitted.classification === 'fitted' ? true : fitted.classification === 'not_fitted' ? false : null,
unresolved: [...reasons].sort(),
} as ContainmentNode;
}).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
const roots = [...new Map(nodes.filter((node) => node.root.kind === 'external' || node.root.kind === 'item').map((node) => [`${node.root.kind}:${node.root.id}`, node.root])).values()]
.sort((left, right) => compareDecimalIds(left.id ?? '1', right.id ?? '1'));
const unresolved = nodes.filter((node) => node.unresolved.length > 0).map((node) => ({ itemId: node.itemId || null, reasons: node.unresolved }));
return { nodes, roots, unresolved, complete: unresolved.length === 0, maxDepth };
}
function finiteTimestamp(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new TypeError(`${field} is invalid`);
return value;
}
function validateObservationIds(ids: unknown, field: string): DecimalId[] {
if (!Array.isArray(ids)) throw new TypeError(`${field} must be an array`);
// Check the raw container before touching any element. This is important for
// duplicate-heavy/proxy inputs and keeps malformed work below the helper cap.
if (ids.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} IDs`);
return ids.map((value, index) => { assertDecimalId(value, `${field}[${index}]`); return value; });
}
function validateBoundedEntries(entries: unknown, field: string): asserts entries is readonly unknown[] {
if (!Array.isArray(entries)) throw new TypeError(`${field} must be an array`);
if (entries.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} entries`);
}
function observationState(value: unknown, fallback: EnrichmentObservationState = 'observed'): EnrichmentObservationState {
const state = value === undefined ? fallback : value;
if (state !== 'observed' && state !== 'not_requested' && state !== 'forbidden' && state !== 'unavailable') throw new TypeError('invalid enrichment observation state');
return state;
}
export type ObservationPlan = {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
};
export type ObservationBatchPlan = ObservationPlan & {
batches: readonly (NamesObservation | LocationsObservation)[];
};
function mergeObservationState(states: readonly EnrichmentObservationState[]): EnrichmentObservationState {
if (states.length === 0) return 'not_requested';
if (states.every((state) => state === 'observed')) return 'observed';
if (states.includes('forbidden')) return 'forbidden';
if (states.includes('unavailable')) return 'unavailable';
return 'not_requested';
}
/** Validate each bounded helper response while retaining all batches in the plan. */
export function normalizeObservationBatches(
observations: readonly (NamesObservation | LocationsObservation)[] | undefined,
kind: 'names' | 'locations',
explicitState?: EnrichmentObservationState,
explicitRequestedIds?: readonly DecimalId[],
): ObservationBatchPlan {
if (!observations || observations.length === 0) {
const plan = normalizeObservationPlan(undefined, kind, explicitState, explicitRequestedIds);
return { ...plan, batches: [] };
}
if (!Number.isSafeInteger(observations.length) || observations.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new RangeError(`${kind} observation batches exceed the generation bound`);
const plans = observations.map((observation) => normalizeObservationPlan(observation, kind));
const requested = plans.flatMap((plan) => plan.requestedIds);
if (new Set(requested).size !== requested.length) throw new TypeError(`${kind} observation batches contain duplicate requested IDs`);
const canonicalRequested = canonicalDecimalIds(requested, `${kind}RequestedIds`);
if (explicitRequestedIds !== undefined) {
if (!Array.isArray(explicitRequestedIds) || explicitRequestedIds.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`${kind}RequestedIds exceeds ${MAX_CONTAINMENT_ITEMS} IDs`);
const explicit = canonicalDecimalIds(explicitRequestedIds.map((value, index) => { assertDecimalId(value, `${kind}RequestedIds[${index}]`); return value; }), `${kind}RequestedIds`);
if (stableSerialize(explicit) !== stableSerialize(canonicalRequested)) throw new TypeError(`${kind} requested IDs conflict with observation batches`);
}
const states = plans.map((plan) => plan.state);
const state = mergeObservationState(states);
if (explicitState !== undefined && explicitState !== state) throw new TypeError(`${kind} state conflicts with observation batches`);
return { state, requestedIds: canonicalRequested, batches: observations };
}
export function normalizeObservationPlan(
observation: NamesObservation | LocationsObservation | undefined,
kind: 'names' | 'locations',
explicitState?: EnrichmentObservationState,
explicitRequestedIds?: readonly DecimalId[],
): ObservationPlan {
const rawExplicitRequested = explicitRequestedIds === undefined ? undefined : validateObservationIds(explicitRequestedIds, `${kind}RequestedIds`);
if (rawExplicitRequested && new Set(rawExplicitRequested).size !== rawExplicitRequested.length) throw new TypeError(`${kind} requested IDs are duplicated`);
const requested = rawExplicitRequested === undefined ? undefined : canonicalDecimalIds(rawExplicitRequested, `${kind}RequestedIds`);
if (observation) {
const embedded = observationState(observation.state);
observationPin(observation, kind);
if (explicitState !== undefined && explicitState !== embedded) throw new TypeError(`${kind} state conflicts with observation state`);
const observedIds = canonicalDecimalIds(observation.requestedIds, `${kind}.requestedIds`);
if (requested && stableSerialize(requested) !== stableSerialize(observedIds)) throw new TypeError(`${kind} requested IDs conflict with observation`);
return { state: embedded, requestedIds: observedIds };
}
const state = observationState(explicitState, requested && requested.length > 0 ? 'unavailable' : 'not_requested');
if (state === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
return { state, requestedIds: requested ?? [] };
}
export function observationPin(observation: NamesObservation | LocationsObservation | undefined, kind: 'names' | 'locations', state: EnrichmentObservationState = 'not_requested'): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
if (!observation) {
const effectiveState = observationState(state, 'not_requested');
if (effectiveState === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
return { state: effectiveState, requestedIds: [], resolvedIds: [], missingIds: [], retrievedAt: null, expiresAt: null, contentDigest: digest(stableSerialize({ state: effectiveState, kind })) };
}
const rawRequestedIds = validateObservationIds(observation.requestedIds, `${kind}.requestedIds`);
if (new Set(rawRequestedIds).size !== rawRequestedIds.length) throw new TypeError(`${kind} observation requested IDs are duplicated`);
const requestedIds = canonicalDecimalIds(rawRequestedIds);
const rawMissingIds = validateObservationIds(observation.missing, `${kind}.missing`);
if (new Set(rawMissingIds).size !== rawMissingIds.length) throw new TypeError(`${kind} observation missing IDs are duplicated`);
const rawNames = (observation as NamesObservation).names;
const rawLocations = (observation as LocationsObservation).locations;
if (kind === 'names') validateBoundedEntries(rawNames, `${kind}.names`);
else validateBoundedEntries(rawLocations, `${kind}.locations`);
const returned = kind === 'names'
? rawNames.map((entry: { itemId: DecimalId }) => entry.itemId)
: rawLocations.map((entry: { itemId: DecimalId }) => entry.itemId);
const resolvedIds = canonicalDecimalIds(returned, `${kind}.resolvedIds`);
const missingIds = canonicalDecimalIds(rawMissingIds);
const requestedSet = new Set(requestedIds);
if (new Set([...resolvedIds, ...missingIds]).size !== resolvedIds.length + missingIds.length) throw new TypeError(`${kind} observation overlaps resolved and missing IDs`);
if ([...resolvedIds, ...missingIds].some((id) => !requestedSet.has(id))) throw new TypeError(`${kind} observation contains an unrequested ID`);
const expectedComplete = missingIds.length === 0 && resolvedIds.length === requestedIds.length;
if (observation.complete !== expectedComplete) throw new TypeError(`${kind} observation completeness is inconsistent`);
if (kind === 'names') {
const names = (observation as NamesObservation).names;
if (new Set(names.map((entry) => entry.itemId)).size !== names.length || names.some((entry) => !isDecimalId(entry.itemId) || typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_ENRICHMENT_NAME_LENGTH)) throw new TypeError('names observation contains invalid or duplicate names');
} else {
const locations = (observation as LocationsObservation).locations;
if (new Set(locations.map((entry) => entry.itemId)).size !== locations.length || locations.some((entry) => !isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z))) throw new TypeError('locations observation contains invalid or duplicate positions');
}
const retrievedAt = finiteTimestamp(observation.retrievedAt, `${kind}.retrievedAt`);
const expiresAt = finiteTimestamp(observation.expiresAt, `${kind}.expiresAt`);
if (expiresAt < retrievedAt) throw new TypeError(`${kind} observation expiry precedes retrieval`);
const effectiveState = observationState(observation.state);
const content = kind === 'names'
? (observation as NamesObservation).names.map((entry: { itemId: DecimalId; name: string }) => ({ itemId: entry.itemId, name: entry.name })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId))
: (observation as LocationsObservation).locations.map((entry: { itemId: DecimalId; position: { x: number; y: number; z: number } }) => ({ itemId: entry.itemId, position: entry.position })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
const eligibleIds = effectiveState === 'observed' ? resolvedIds : [];
const eligibleMissing = effectiveState === 'observed' ? missingIds : [];
const contentDigest = digest(stableSerialize(effectiveState === 'observed' ? content : { state: effectiveState, requestedIds }));
return { state: effectiveState, requestedIds, resolvedIds: eligibleIds, missingIds: eligibleMissing, retrievedAt, expiresAt, contentDigest };
}
function observationBatchPin(
observations: readonly (NamesObservation | LocationsObservation)[],
kind: 'names' | 'locations',
state: EnrichmentObservationState,
requestedIds: readonly DecimalId[],
): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
// Preserve each batch's own state/content digest. An unavailable batch must
// not erase a successful neighboring batch from the generation pin.
const pins = observations.map((observation) => observationPin(observation, kind));
const resolvedIds = canonicalDecimalIds(pins.flatMap((pin) => pin.resolvedIds), `${kind}.resolvedIds`);
const missingIds = canonicalDecimalIds(pins.flatMap((pin) => pin.missingIds), `${kind}.missingIds`);
const times = pins.flatMap((pin) => [pin.retrievedAt, pin.expiresAt]).filter((value): value is number => value !== null);
return {
state,
requestedIds: [...requestedIds],
resolvedIds,
missingIds,
retrievedAt: times.length ? Math.min(...pins.flatMap((pin) => pin.retrievedAt).filter((value): value is number => value !== null)) : null,
expiresAt: times.length ? Math.min(...pins.flatMap((pin) => pin.expiresAt).filter((value): value is number => value !== null)) : null,
contentDigest: digest(stableSerialize({ kind, state, requestedIds, batches: pins.map((pin) => ({ ...pin, requestedIds: undefined })) })),
batches: pins,
} as ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'];
}
export function createReferenceEnrichmentPin(args: {
assetGeneration: string;
authority: InventoryLocationAuthority;
referenceVersion: string;
names?: NamesObservation;
namesBatches?: readonly NamesObservation[];
locations?: LocationsObservation;
locationsBatches?: readonly LocationsObservation[];
namesState?: EnrichmentObservationState;
locationsState?: EnrichmentObservationState;
namesRequestedIds?: readonly DecimalId[];
locationsRequestedIds?: readonly DecimalId[];
structureRevision?: string;
structureObservations?: readonly StructureObservationPin[];
referenceFacts?: readonly ReferenceLocationFactPin[];
containmentDepth?: number;
}): ReferenceEnrichmentPin {
if (typeof args.assetGeneration !== 'string' || !args.assetGeneration) throw new TypeError('asset generation is required');
if (typeof args.referenceVersion !== 'string' || !args.referenceVersion) throw new TypeError('reference version is required');
const containmentDepth = args.containmentDepth ?? DEFAULT_CONTAINMENT_DEPTH;
if (!Number.isSafeInteger(containmentDepth) || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError('containment depth is invalid');
const authority = validateAuthorityContext(args.authority);
const namesBatches = args.namesBatches ?? (args.names ? [args.names] : []);
const locationsBatches = args.locationsBatches ?? (args.locations ? [args.locations] : []);
const namesPlan = normalizeObservationBatches(namesBatches, 'names', args.namesState, args.namesRequestedIds);
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', args.locationsState, args.locationsRequestedIds);
const namesObservation = args.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined);
const locationsObservation = args.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined);
const names = namesBatches.length > 1
? observationBatchPin(namesBatches, 'names', namesPlan.state, namesPlan.requestedIds) as ReferenceEnrichmentPin['names']
: observationPin(namesObservation, 'names', namesPlan.state) as ReferenceEnrichmentPin['names'];
const locations = locationsBatches.length > 1
? observationBatchPin(locationsBatches, 'locations', locationsPlan.state, locationsPlan.requestedIds) as ReferenceEnrichmentPin['locations']
: observationPin(locationsObservation, 'locations', locationsPlan.state) as ReferenceEnrichmentPin['locations'];
if (!namesObservation && namesBatches.length <= 1) {
names.requestedIds = namesPlan.requestedIds;
names.contentDigest = digest(stableSerialize({ state: names.state, requestedIds: names.requestedIds }));
}
if (!locationsObservation && locationsBatches.length <= 1) {
locations.requestedIds = locationsPlan.requestedIds;
locations.contentDigest = digest(stableSerialize({ state: locations.state, requestedIds: locations.requestedIds }));
}
const structures = args.structureObservations?.map((observation) => ({ ...observation })) ?? undefined;
if (structures && structures.length > 1_000) throw new RangeError('structure observations exceed the generation bound');
const referenceFacts = args.referenceFacts?.map((fact) => ({ ...fact })).sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)) ?? undefined;
if (referenceFacts && referenceFacts.length > 3_000) throw new RangeError('reference location facts exceed the generation bound');
return { version: 1, assetGeneration: args.assetGeneration, authority, referenceVersion: args.referenceVersion, containmentDepth, names, locations, ...(structures === undefined ? {} : { structures }), ...(referenceFacts === undefined ? {} : { referenceFacts, referenceFactsDigest: digest(stableSerialize(referenceFacts)) }), structureRevision: args.structureRevision ?? 'none' };
}
/** Validate an immutable generation pin at the persistence boundary. */
export function validateReferenceEnrichmentPin(value: unknown, expected?: { assetGeneration?: string; referenceVersion?: string; authority?: InventoryLocationAuthority }): asserts value is ReferenceEnrichmentPin {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('enrichment pin is invalid');
const candidate = value as Partial<ReferenceEnrichmentPin>;
const allowedPin = new Set(['version', 'assetGeneration', 'authority', 'referenceVersion', 'containmentDepth', 'names', 'locations', 'structures', 'referenceFacts', 'referenceFactsDigest', 'structureRevision']);
if (Object.keys(candidate).some((key) => !allowedPin.has(key))) throw new TypeError('enrichment pin has unknown fields');
if (candidate.version !== 1 || typeof candidate.assetGeneration !== 'string' || candidate.assetGeneration.length === 0 || candidate.assetGeneration.length > 256) throw new TypeError('enrichment pin generation is invalid');
if (expected?.assetGeneration !== undefined && candidate.assetGeneration !== expected.assetGeneration) throw new TypeError('enrichment pin generation does not match');
if (typeof candidate.referenceVersion !== 'string' || candidate.referenceVersion.length === 0 || candidate.referenceVersion.length > 256) throw new TypeError('enrichment pin reference version is invalid');
if (expected?.referenceVersion !== undefined && candidate.referenceVersion !== expected.referenceVersion) throw new TypeError('enrichment pin reference version does not match');
if (!candidate.authority || typeof candidate.authority !== 'object' || Array.isArray(candidate.authority) || Object.keys(candidate.authority as object).some((key) => !new Set(['userId', 'principal', 'characterId', 'scopes', 'grantRevision', 'authorizationEpoch', 'credentialVersion']).has(key))) throw new TypeError('enrichment pin authority has unknown fields');
const rawAuthority = candidate.authority as Partial<InventoryLocationAuthority>;
const rawScopes = rawAuthority.scopes;
if (!Array.isArray(rawScopes) || stableSerialize(rawScopes) !== stableSerialize([...rawScopes].sort())) throw new TypeError('enrichment pin authority scopes are not canonical');
const authority = validateAuthorityContext(candidate.authority);
if (authority.userId.length > 256 || authority.principal.length > 256 || authority.scopes.length > 64 || authority.scopes.some((scope) => scope.length > 128)) throw new TypeError('enrichment pin authority bounds are invalid');
if (expected?.authority) {
const required = validateAuthorityContext(expected.authority);
if (authority.userId !== required.userId || authority.principal !== required.principal || authority.characterId !== required.characterId || authority.grantRevision !== required.grantRevision || authority.authorizationEpoch !== required.authorizationEpoch || authority.credentialVersion !== required.credentialVersion || stableSerialize(authority.scopes) !== stableSerialize(required.scopes)) throw new TypeError('enrichment pin authority does not match');
}
const containmentDepth = candidate.containmentDepth;
if (!Number.isSafeInteger(containmentDepth) || typeof containmentDepth !== 'number' || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH || typeof candidate.structureRevision !== 'string' || candidate.structureRevision.length === 0 || candidate.structureRevision.length > 256) throw new TypeError('enrichment pin bounds are invalid');
const validatePart = (part: unknown, field: 'names' | 'locations'): void => {
if (!part || typeof part !== 'object' || Array.isArray(part)) throw new TypeError(`enrichment pin ${field} is invalid`);
const item = part as ReferenceEnrichmentPin['names'];
const allowedPart = new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest', 'batches']);
if (Object.keys(item).some((key) => !allowedPart.has(key))) throw new TypeError(`enrichment pin ${field} has unknown fields`);
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(item.state)) throw new TypeError(`enrichment pin ${field} state is invalid`);
const lists = [item.requestedIds, item.resolvedIds, item.missingIds];
if (lists.some((list) => !Array.isArray(list) || list.length > MAX_CONTAINMENT_ITEMS)) throw new TypeError(`enrichment pin ${field} IDs are oversized`);
const requested = canonicalDecimalIds(item.requestedIds, `${field}.requestedIds`);
const resolved = canonicalDecimalIds(item.resolvedIds, `${field}.resolvedIds`);
const missing = canonicalDecimalIds(item.missingIds, `${field}.missingIds`);
if (stableSerialize(requested) !== stableSerialize(item.requestedIds) || stableSerialize(resolved) !== stableSerialize(item.resolvedIds) || stableSerialize(missing) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} IDs are not canonical`);
const requestedSet = new Set(requested);
if ([...resolved, ...missing].some((id) => !requestedSet.has(id)) || new Set([...resolved, ...missing]).size !== resolved.length + missing.length) throw new TypeError(`enrichment pin ${field} IDs are inconsistent`);
if (typeof item.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(item.contentDigest)) throw new TypeError(`enrichment pin ${field} digest is invalid`);
const times = [item.retrievedAt, item.expiresAt];
if (times.some((time) => time !== null && (typeof time !== 'number' || !Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (item.retrievedAt === null) !== (item.expiresAt === null) || (item.retrievedAt !== null && item.expiresAt !== null && item.expiresAt < item.retrievedAt)) throw new TypeError(`enrichment pin ${field} freshness is invalid`);
if (item.state === 'not_requested' && (item.requestedIds.length !== 0 || item.resolvedIds.length !== 0 || item.missingIds.length !== 0 || item.retrievedAt !== null || item.expiresAt !== null)) throw new TypeError(`enrichment pin ${field} not-requested state is inconsistent`);
if (item.state === 'observed' && (item.retrievedAt === null || item.expiresAt === null)) throw new TypeError(`enrichment pin ${field} observed state is missing freshness`);
if (item.batches !== undefined) {
if (!Array.isArray(item.batches) || item.batches.length === 0 || item.batches.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new TypeError(`enrichment pin ${field} batches are oversized`);
const requestedUnion = new Set<string>(); const resolvedUnion = new Set<string>(); const missingUnion = new Set<string>();
const states: EnrichmentObservationState[] = [];
for (const batch of item.batches) {
if (!batch || typeof batch !== 'object' || Array.isArray(batch) || Object.keys(batch).some((key) => !new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest']).has(key))) throw new TypeError(`enrichment pin ${field} batch is invalid`);
const candidateBatch = batch as ReferenceEnrichmentBatchPin;
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(candidateBatch.state) || !Array.isArray(candidateBatch.requestedIds) || candidateBatch.requestedIds.length > MAX_ENRICHMENT_IDS || !Array.isArray(candidateBatch.resolvedIds) || !Array.isArray(candidateBatch.missingIds)) throw new TypeError(`enrichment pin ${field} batch shape is invalid`);
const batchRequested = canonicalDecimalIds(candidateBatch.requestedIds, `${field}.batch.requestedIds`);
const batchResolved = canonicalDecimalIds(candidateBatch.resolvedIds, `${field}.batch.resolvedIds`);
const batchMissing = canonicalDecimalIds(candidateBatch.missingIds, `${field}.batch.missingIds`);
if (stableSerialize(batchRequested) !== stableSerialize(candidateBatch.requestedIds) || stableSerialize(batchResolved) !== stableSerialize(candidateBatch.resolvedIds) || stableSerialize(batchMissing) !== stableSerialize(candidateBatch.missingIds) || batchResolved.some((id) => !batchRequested.includes(id)) || batchMissing.some((id) => !batchRequested.includes(id)) || new Set([...batchResolved, ...batchMissing]).size !== batchResolved.length + batchMissing.length) throw new TypeError(`enrichment pin ${field} batch IDs are inconsistent`);
if (candidateBatch.state !== 'observed' && (batchResolved.length > 0 || batchMissing.length > 0)) throw new TypeError(`enrichment pin ${field} batch failure retains body IDs`);
if (typeof candidateBatch.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(candidateBatch.contentDigest) || [candidateBatch.retrievedAt, candidateBatch.expiresAt].some((time) => time !== null && (!Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (candidateBatch.retrievedAt === null) !== (candidateBatch.expiresAt === null) || (candidateBatch.retrievedAt !== null && candidateBatch.expiresAt !== null && candidateBatch.expiresAt < candidateBatch.retrievedAt)) throw new TypeError(`enrichment pin ${field} batch freshness is invalid`);
states.push(candidateBatch.state); batchRequested.forEach((id) => requestedUnion.add(id)); batchResolved.forEach((id) => resolvedUnion.add(id)); batchMissing.forEach((id) => missingUnion.add(id));
}
const mergedState = states.every((state) => state === 'observed') ? 'observed' : states.includes('forbidden') ? 'forbidden' : states.includes('unavailable') ? 'unavailable' : 'not_requested';
if (mergedState !== item.state || stableSerialize(canonicalDecimalIds([...requestedUnion])) !== stableSerialize(item.requestedIds) || stableSerialize(canonicalDecimalIds([...resolvedUnion])) !== stableSerialize(item.resolvedIds) || stableSerialize(canonicalDecimalIds([...missingUnion])) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} aggregate batches are inconsistent`);
} else if (item.state !== 'observed' && (item.resolvedIds.length !== 0 || item.missingIds.length !== 0)) throw new TypeError(`enrichment pin ${field} unresolved state has resolved IDs`);
};
validatePart(candidate.names, 'names');
validatePart(candidate.locations, 'locations');
if (candidate.referenceFacts !== undefined) {
if (!Array.isArray(candidate.referenceFacts) || candidate.referenceFacts.length > 3_000 || typeof candidate.referenceFactsDigest !== 'string' || candidate.referenceFactsDigest !== digest(stableSerialize(candidate.referenceFacts))) throw new TypeError('enrichment pin reference facts are invalid');
let previousFact = '';
for (const fact of candidate.referenceFacts) {
if (!fact || typeof fact !== 'object' || Array.isArray(fact) || Object.keys(fact).some((key) => !new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'systemId', 'regionId', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin reference fact is invalid');
const referenceFact = fact as ReferenceLocationFactPin;
assertDecimalId(referenceFact.id, 'enrichment pin reference fact ID');
if (!['station', 'system', 'region'].includes(referenceFact.kind) || (referenceFact.kind === 'station' && !['verified', 'system_listed'].includes(referenceFact.evidence)) || (referenceFact.kind !== 'station' && referenceFact.evidence !== 'verified') || referenceFact.sourceVerified !== (referenceFact.evidence === 'verified') || (referenceFact.name !== null && (typeof referenceFact.name !== 'string' || !referenceFact.name.trim() || referenceFact.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (referenceFact.parentId !== null && !isDecimalId(referenceFact.parentId)) || (referenceFact.systemId !== null && !isDecimalId(referenceFact.systemId)) || (referenceFact.regionId !== null && !isDecimalId(referenceFact.regionId)) || (referenceFact.kind === 'station' && referenceFact.parentId !== referenceFact.systemId) || (referenceFact.kind === 'system' && referenceFact.parentId !== referenceFact.regionId) || (referenceFact.kind === 'region' && (referenceFact.parentId !== null || referenceFact.systemId !== null || referenceFact.regionId !== null)) || referenceFact.referenceVersion !== candidate.referenceVersion) throw new TypeError('enrichment pin reference fact is inconsistent');
const key = `${referenceFact.kind}:${referenceFact.id}`;
if (key <= previousFact) throw new TypeError('enrichment pin reference facts are not canonical');
previousFact = key;
}
} else if (candidate.referenceFactsDigest !== undefined) throw new TypeError('enrichment pin reference facts digest is orphaned');
if (candidate.structures !== undefined) {
if (!Array.isArray(candidate.structures) || candidate.structures.length > 1_000) throw new TypeError('enrichment pin structure observations are oversized');
const seenStructures = new Set<string>();
for (const structure of candidate.structures) {
if (!structure || typeof structure !== 'object' || Array.isArray(structure) || Object.keys(structure).some((key) => !new Set(['structureId', 'characterId', 'status', 'evidence', 'name', 'solarSystemId', 'ownerId', 'typeId', 'observedAt', 'expiresAt', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin structure observation is invalid');
const observation = structure as StructureObservationPin;
assertDecimalId(observation.structureId, 'enrichment pin structure ID');
if (seenStructures.has(observation.structureId) || !Number.isSafeInteger(observation.characterId) || observation.characterId !== authority.characterId || !['accessible', 'forbidden', 'unavailable'].includes(observation.status) || !['esi_structure_access', 'esi_forbidden', 'unavailable'].includes(observation.evidence) || (observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') || (observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') || (observation.status === 'unavailable' && observation.evidence !== 'unavailable') || (observation.status !== 'accessible' && (observation.name !== null || observation.solarSystemId !== null || observation.ownerId !== null || observation.typeId !== null)) || (observation.name !== null && (typeof observation.name !== 'string' || !observation.name.trim() || observation.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (observation.solarSystemId !== null && !isDecimalId(observation.solarSystemId)) || (observation.ownerId !== null && !isDecimalId(observation.ownerId)) || (observation.typeId !== null && !isDecimalId(observation.typeId)) || typeof observation.referenceVersion !== 'string' || observation.referenceVersion !== candidate.referenceVersion || !Number.isSafeInteger(observation.observedAt) || !Number.isSafeInteger(observation.expiresAt) || observation.observedAt < 0 || observation.expiresAt < observation.observedAt || observation.expiresAt > 8_640_000_000_000_000) throw new TypeError('enrichment pin structure observation is inconsistent');
seenStructures.add(observation.structureId);
}
}
}
/** Stable JSON for pin hashes; object insertion order and caller ID order never affect it. */
export function stableSerialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}`;
}
function digest(value: string): string {
// Two independent 32-bit FNV passes avoid a Node-only dependency in this domain module.
let first = 2166136261;
let second = 2166136261 ^ 0x9e3779b9;
for (let index = 0; index < value.length; index += 1) {
const code = value.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')}`;
}
export function deterministicEnrichmentVersion(pin: ReferenceEnrichmentPin, algorithmVersion = 'containment-v1'): string {
return `${algorithmVersion}-${digest(stableSerialize(pin))}`;
}
export function nameStatusFor(id: DecimalId, observation: NamesObservation | undefined, now: number, requested = true): EnrichmentNameStatus {
if (!requested || !observation) return 'not_requested';
const state = observationState(observation.state);
if (state === 'not_requested') return 'not_requested';
if (state === 'forbidden') return 'forbidden';
if (state === 'unavailable') return 'unavailable';
if (now < observation.retrievedAt) return 'unavailable';
if (now >= observation.expiresAt) return 'expired';
if (observation.names.some((entry) => entry.itemId === id)) return 'resolved';
if (observation.missing.includes(id)) return 'missing';
return 'unavailable';
}
export function locationStatusFor(id: DecimalId, observation: LocationsObservation | undefined, now: number, requested = true): EnrichmentLocationStatus {
if (!requested || !observation) return 'not_requested';
const state = observationState(observation.state);
if (state === 'not_requested') return 'not_requested';
if (state === 'forbidden') return 'forbidden';
if (state === 'unavailable') return 'unavailable';
if (now < observation.retrievedAt) return 'unavailable';
if (now >= observation.expiresAt) return 'expired';
if (observation.locations.some((entry) => entry.itemId === id)) return 'resolved';
if (observation.missing.includes(id)) return 'missing';
return 'unavailable';
}
+1 -1
View File
@@ -84,7 +84,7 @@ export interface MarketEstimates {
estimates(typeIds: number[]): Promise<PriceSnapshot>;
}
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data' | 'wallet_balance' | 'reference_data';
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities' | 'inventory_query' | 'wallet_balance';
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities' | 'inventory_query' | 'inventory_locations' | 'wallet_balance';
export type Action = { kind: 'connect_character' | 'synchronize'; label: string; href: string };
export type MissingRequirement = { requirement: Requirement; characterId?: number; reason: 'authorization' | 'synchronization' | 'stale' | 'not_implemented' };
export type CapabilityGate =
+74 -2
View File
@@ -10,6 +10,8 @@ import { decimalIskToCents } from "../../domain/money";
export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const;
export const ESI_BASE_URL = "https://esi.evetech.net" as const;
export const ESI_ASSETS_SCOPE = "esi-assets.read_assets.v1" as const;
/** Character-scoped private structure endpoint scope. */
export const ESI_STRUCTURES_SCOPE = "esi-universe.read_structures.v1" as const;
export const ESI_WALLET_SCOPE = "esi-wallet.read_character_wallet.v1" as const;
/** Endpoint-specific bounds are deliberately well below the old unbounded page cap. */
export const ESI_ASSET_MAX_PAGES = 1_000;
@@ -19,6 +21,7 @@ export const ESI_ASSET_HELPER_MAX_IDS = 1_000;
export const ESI_ASSET_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export const ESI_ASSET_NAME_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
export const ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES = 512 * 1024;
export const ESI_STRUCTURE_MAX_RESPONSE_BYTES = 64 * 1024;
export const ESI_WALLET_MAX_RESPONSE_BYTES = 64 * 1024;
export const ESI_MARKET_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
export const ESI_MARKET_MAX_ROWS = 20_000;
@@ -28,6 +31,7 @@ export const ESI_READ_OPERATIONS = {
characterAssets: { method: "GET", path: "characters/{character_id}/assets", pagination: "x-pages", requiredScope: ESI_ASSETS_SCOPE, maxPages: ESI_ASSET_MAX_PAGES, maxRows: ESI_ASSET_MAX_ROWS_PER_PAGE, maxResponseBytes: ESI_ASSET_MAX_RESPONSE_BYTES },
characterAssetNames: { method: "POST", path: "characters/{character_id}/assets/names", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_NAME_MAX_RESPONSE_BYTES },
characterAssetLocations: { method: "POST", path: "characters/{character_id}/assets/locations", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES },
characterStructure: { method: "GET", path: "universe/structures/{structure_id}", pagination: "none", requiredScope: ESI_STRUCTURES_SCOPE, maxRows: 1, maxResponseBytes: ESI_STRUCTURE_MAX_RESPONSE_BYTES },
characterWalletBalance: { method: "GET", path: "characters/{character_id}/wallet", pagination: "none", requiredScope: ESI_WALLET_SCOPE, maxRows: 1, maxResponseBytes: ESI_WALLET_MAX_RESPONSE_BYTES },
marketPrices: { method: "GET", path: "markets/prices", pagination: "none", requiredScope: undefined, maxRows: ESI_MARKET_MAX_ROWS, maxResponseBytes: ESI_MARKET_MAX_RESPONSE_BYTES },
} as const;
@@ -117,6 +121,18 @@ export type EsiAssetLocation = {
position: { x: number; y: number; z: number };
};
/** A private structure response is access evidence only; docking remains unknown. */
export type EsiStructureObservation = {
structureId: string;
name: string | null;
solarSystemId: string | null;
ownerId: string | null;
typeId: string | null;
position: { x: number; y: number; z: number } | null;
retrievedAt: number;
expiresAt: number;
};
/** A helper response is intentionally explicit about IDs ESI did not return. */
export type EsiAssetNamesObservation = {
requestedIds: readonly string[];
@@ -435,8 +451,8 @@ function parseAssetNames(
if (!wanted.has(itemId)) throw new EsiSchemaError(endpoint, `name ${index}.item_id was not requested`);
if (seen.has(itemId)) throw new EsiSchemaError(endpoint, `duplicate item_id ${itemId}`);
seen.add(itemId);
if (typeof raw.name !== "string" || raw.name.length === 0) {
throw new EsiSchemaError(endpoint, `name ${index}.name must be a non-empty string`);
if (typeof raw.name !== "string" || raw.name.length === 0 || raw.name.length > 512) {
throw new EsiSchemaError(endpoint, `name ${index}.name must be a bounded non-empty string`);
}
return { itemId, name: raw.name };
});
@@ -471,6 +487,34 @@ function parseAssetLocations(
return { locations, missing: requestedIds.filter((itemId) => !seen.has(itemId)) };
}
/** Parse the fixed, character-scoped private structure response without coercing IDs through Number. */
function parseStructure(body: string, endpoint: string, requestedStructureId: string): Omit<EsiStructureObservation, 'retrievedAt' | 'expiresAt'> {
const value = parseJson(body, endpoint);
if (!isRecord(value)) throw new EsiSchemaError(endpoint, "body must be an object");
const structureId = requestedStructureId;
const solarSystemId = value.solar_system_id === undefined || value.solar_system_id === null
? null : positiveDecimalId(value.solar_system_id, "solar_system_id", endpoint);
const ownerId = value.owner_id === undefined || value.owner_id === null
? null : positiveDecimalId(value.owner_id, "owner_id", endpoint);
const typeId = value.type_id === undefined || value.type_id === null
? null : positiveDecimalId(value.type_id, "type_id", endpoint);
const name = value.name;
if (typeof name !== "string" || name.length === 0 || name.length > 512) {
throw new EsiSchemaError(endpoint, "name must be a bounded non-empty string");
}
if (solarSystemId === null || ownerId === null) throw new EsiSchemaError(endpoint, "solar_system_id and owner_id are required");
let position: { x: number; y: number; z: number } | null = null;
if (value.position !== undefined && value.position !== null) {
if (!isRecord(value.position)) throw new EsiSchemaError(endpoint, "position must be an object");
const x = Number(numberText(value.position.x, "position.x", endpoint));
const y = Number(numberText(value.position.y, "position.y", endpoint));
const z = Number(numberText(value.position.z, "position.z", endpoint));
if (![x, y, z].every(Number.isFinite)) throw new EsiSchemaError(endpoint, "position must contain finite coordinates");
position = { x, y, z };
}
return { structureId, name, solarSystemId, ownerId, typeId, position };
}
function parseWalletBalance(body: string, endpoint: string): string {
const value = parseJson(body, endpoint);
const text = numberText(value, "wallet balance", endpoint);
@@ -1379,6 +1423,34 @@ export class EsiClient {
};
}
/**
* Read one private structure through the fixed typed endpoint. The cache key
* includes the complete character authority context, so this cannot leak a
* structure observation across users, grants, or credentials.
*/
public async getStructure(
characterId: number,
structureId: string | number | bigint,
accessToken: string,
authorizationKey: AuthorizationKey,
): Promise<EsiStructureObservation> {
validateCharacterId(characterId);
validateAccessToken(accessToken);
const canonicalId = canonicalAssetIds([structureId], "/universe/structures")[0]!;
const endpoint = `/universe/structures/${canonicalId}`;
const context = authorizationContext(characterId, authorizationKey, ESI_STRUCTURES_SCOPE);
const result = await this.read(endpoint, `structure:${context}:${canonicalId}`, {
method: "GET",
operation: "characterStructure",
accessToken,
fallbackTtlMs: 60 * 60 * 1000,
rateLimitContext: context,
validateBody: (responseBody) => { parseStructure(responseBody, endpoint, canonicalId); },
});
const parsed = parseStructure(result.body, endpoint, canonicalId);
return { ...parsed, retrievedAt: result.entry.receivedAt, expiresAt: result.entry.expiresAt };
}
/** Read a single signed-ISK wallet balance; T04 normalizes sub-cent values. */
public async getWalletBalance(
characterId: number,
+59
View File
@@ -0,0 +1,59 @@
import type { Asset } from '../../domain/model';
import {
DEFAULT_CONTAINMENT_DEPTH,
MAX_CONTAINMENT_ITEMS,
resolveContainment,
type ContainmentInput,
type ContainmentResult,
} from '../../domain/inventory-location';
export { classifyFittedFlag, compareDecimalIds, isDecimalId, resolveContainment } from '../../domain/inventory-location';
export type {
Breadcrumb,
ContainmentInput,
ContainmentNode,
ContainmentResult,
ContainmentRoot,
FittedClassification,
FittedClassificationDetail,
} from '../../domain/inventory-location';
export const CONTAINMENT_MAX_ITEMS = MAX_CONTAINMENT_ITEMS;
export const CONTAINMENT_MAX_DEPTH = DEFAULT_CONTAINMENT_DEPTH;
/**
* Feature-local façade used by the sync worker. The resolver is pure: no
* reference lookups, private names, or valuation are performed here, so a
* staged result can be retried without changing its parent graph.
*/
export class ContainmentResolver {
readonly maxDepth: number;
constructor(maxDepth = DEFAULT_CONTAINMENT_DEPTH) {
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) {
throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
}
this.maxDepth = maxDepth;
}
resolve(input: Omit<ContainmentInput, 'maxDepth'>): ContainmentResult {
return resolveContainment({ ...input, maxDepth: this.maxDepth });
}
}
export function resolveContainmentGraph(input: ContainmentInput): ContainmentResult {
return resolveContainment(input);
}
/** Compatibility name for callers that describe this operation as a graph walk. */
export const resolveContainmentTree = resolveContainmentGraph;
export const buildContainmentGraph = resolveContainmentGraph;
export const resolveAssetContainment = resolveContainmentGraph;
/** A helper for sync adapters that only need to stage the source assets. */
export function validateContainmentInput(assets: readonly Asset[], characterId: number): void {
if (!Array.isArray(assets) || assets.length > CONTAINMENT_MAX_ITEMS) throw new RangeError(`containment item cap exceeded (${CONTAINMENT_MAX_ITEMS})`);
if (!Number.isSafeInteger(characterId) || characterId < 0) throw new TypeError('containment character ID is invalid');
// resolveContainment performs the full runtime validation and fails closed;
// calling it here intentionally avoids a second, potentially divergent validator.
resolveContainment({ assets, characterId, maxDepth: 0 });
}
@@ -0,0 +1,400 @@
import type {
ReferenceReader,
ReferenceReaderHandle,
ReferenceReaderPort,
ReferenceStationRecord,
ReferenceSystemRecord,
} from '../../domain/reference';
import type { Asset } from '../../domain/model';
import {
createReferenceEnrichmentPin,
decimalIdToSafeNumber,
compareDecimalIds,
DEFAULT_CONTAINMENT_DEPTH,
deterministicEnrichmentVersion,
isDecimalId,
locationStatusFor,
nameStatusFor,
resolveContainment,
validateAuthorityContext,
type DecimalId,
type EnrichedLocationNode,
type EnrichmentLocationStatus,
type EnrichmentNameStatus,
type EnrichmentObservationState,
type LocationEnrichmentResult,
type LocationReference,
type LocationsObservation,
type NamesObservation,
normalizeObservationBatches,
type ReferenceEnrichmentPin,
type ReferenceLocationFactPin,
type InventoryLocationAuthority,
type LocationUnresolved,
type UnresolvedReason,
} from '../../domain/inventory-location';
import { EsiError, EsiHttpError } from '../esi';
import {
validatePrivateStructureContext,
type PrivateStructureAccessObservation,
type PrivateStructureContext,
type PrivateStructurePort,
} from './private-structure-cache';
export const CHARACTER_ASSETS_SCOPE = 'esi-assets.read_assets.v1' as const;
export const MAX_REFERENCE_ROOTS = 1_000;
export type LocationEnrichmentRequest = {
assets: readonly Asset[];
authority: InventoryLocationAuthority;
/** T07's staged snapshot generation; it is never inferred from asset content. */
assetGeneration: string;
names?: NamesObservation;
namesBatches?: readonly NamesObservation[];
locations?: LocationsObservation;
locationsBatches?: readonly LocationsObservation[];
namesState?: 'not_requested' | 'forbidden' | 'unavailable';
locationsState?: 'not_requested' | 'forbidden' | 'unavailable';
namesRequestedIds?: readonly DecimalId[];
locationsRequestedIds?: readonly DecimalId[];
structureRevision?: string;
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
maxDepth?: number;
};
export type LocationServiceOptions = {
reference: ReferenceReaderPort | ReferenceReaderHandle | ReferenceReader;
structures?: PrivateStructurePort;
now?: () => number;
maxDepth?: number;
};
type PinnedReference = ReferenceReader & { pinnedVersion: string; close?: () => void };
type RootOutcome = {
location: LocationReference;
reasons: readonly UnresolvedReason[];
structureObservation?: PrivateStructureAccessObservation;
};
function finiteNow(clock: () => number): number {
const value = clock();
if (!Number.isFinite(value)) throw new RangeError('location service clock must return a finite timestamp');
return value;
}
function decimalIdForReference(id: DecimalId): number | null { return decimalIdToSafeNumber(id); }
function referenceId(value: number): DecimalId { return String(value); }
function uniqueReasons(reasons: readonly UnresolvedReason[]): UnresolvedReason[] { return [...new Set(reasons)].sort(); }
function nameMap(observation: NamesObservation | undefined, batches: readonly NamesObservation[] = []): Map<DecimalId, string> {
const result = new Map<DecimalId, string>();
for (const source of batches.length ? batches : observation ? [observation] : []) {
for (const entry of source.names) if (isDecimalId(entry.itemId) && typeof entry.name === 'string' && entry.name.trim()) result.set(entry.itemId, entry.name);
}
return result;
}
function positionMap(observation: LocationsObservation | undefined, batches: readonly LocationsObservation[] = []): Map<DecimalId, { x: number; y: number; z: number }> {
const result = new Map<DecimalId, { x: number; y: number; z: number }>();
for (const source of batches.length ? batches : observation ? [observation] : []) {
for (const entry of source.locations) {
if (!isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z)) continue;
result.set(entry.itemId, { ...entry.position });
}
}
return result;
}
function observationMap<T extends NamesObservation | LocationsObservation>(observations: readonly T[]): Map<DecimalId, T> {
const result = new Map<DecimalId, T>();
for (const observation of observations) for (const id of observation.requestedIds) result.set(id, observation);
return result;
}
function privateContext(authority: InventoryLocationAuthority, referenceVersion: string): PrivateStructureContext {
return validatePrivateStructureContext({ ...validateAuthorityContext(authority), referenceVersion });
}
function helperStatus<T extends EnrichmentNameStatus | EnrichmentLocationStatus>(
observation: NamesObservation | LocationsObservation | undefined,
requested: boolean,
state: EnrichmentObservationState | undefined,
resolver: (id: DecimalId, observation: any, now: number, requested: boolean) => T,
id: DecimalId,
now: number,
): T {
if (observation) return resolver(id, observation, now, requested);
if (!requested || state === 'not_requested') return 'not_requested' as T;
if (state === 'observed') throw new TypeError('observed helper state requires an observation');
return (state ?? 'unavailable') as T;
}
function emptyUnknown(id: DecimalId, version: string): LocationReference {
return { kind: 'unknown', id, name: null, parentId: null, evidence: 'unknown', sourceVerified: null, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
}
function stationReference(station: ReferenceStationRecord, version: string, system: LocationReference | null, region: LocationReference | null): LocationReference {
return {
kind: 'station', id: referenceId(station.id), name: typeof station.name === 'string' ? station.name : null, parentId: referenceId(station.systemId), evidence: station.evidence, sourceVerified: station.sourceVerified,
referenceVersion: version, dockingPermission: 'unknown', system, region,
};
}
function systemReference(system: ReferenceSystemRecord, version: string, region: LocationReference | null): LocationReference {
return {
kind: 'system', id: referenceId(system.id), name: typeof system.name === 'string' ? system.name : null, parentId: system.regionId === null ? null : referenceId(system.regionId), evidence: 'verified', sourceVerified: true,
referenceVersion: version, dockingPermission: 'unknown', system: null, region,
};
}
function regionReference(id: number, name: string | null, version: string): LocationReference {
return { kind: 'region', id: referenceId(id), name, parentId: null, evidence: 'verified', sourceVerified: true, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
}
/**
* Resolve one immutable reference handle for an entire enrichment. Callers
* cannot observe a station from one reference artifact and its region from
* another. The returned handle is closed after all reads complete.
*/
function pinReference(reference: LocationServiceOptions['reference']): PinnedReference {
const candidate = reference as Partial<ReferenceReaderPort> & Partial<ReferenceReaderHandle>;
if (typeof candidate.pin === 'function') {
const pinned = candidate.pin() as PinnedReference;
if (!pinned || typeof pinned.pinnedVersion !== 'string' || !pinned.pinnedVersion) {
try { pinned?.close?.(); } catch { /* preserve the pin validation failure */ }
throw new Error('reference pin did not provide a version');
}
return pinned;
}
const version = candidate.pinnedVersion;
if (typeof version !== 'string' || !version) throw new Error('reference reader must provide a pinned version');
return reference as PinnedReference;
}
async function readRegion(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
const rows = await reader.regions({ ids: [id] });
const row = rows.find((candidate) => candidate.id === id);
return row ? regionReference(row.id, row.name, version) : null;
}
async function readSystem(reader: ReferenceReader, id: number, version: string): Promise<{ reference: LocationReference | null; row: ReferenceSystemRecord | null }> {
const rows = await reader.systems({ ids: [id] });
const row = rows.find((candidate) => candidate.id === id);
if (!row) return { reference: null, row: null };
const region = row.regionId === null ? null : await readRegion(reader, row.regionId, version);
return { reference: systemReference(row, version, region), row };
}
async function readStation(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
const rows = await reader.stations({ ids: [id] });
const station = rows.find((candidate) => candidate.id === id);
if (!station) return null;
const systemResult = await readSystem(reader, station.systemId, version);
const region = systemResult.reference?.region ?? (station.regionId === null ? null : await readRegion(reader, station.regionId, version));
return stationReference(station, version, systemResult.reference, region);
}
async function structureLocation(value: PrivateStructureAccessObservation, version: string, reader: ReferenceReader): Promise<{ location: LocationReference; reasons: UnresolvedReason[] }> {
let system: LocationReference | null = null;
const reasons: UnresolvedReason[] = [];
if (value.status === 'accessible' && value.solarSystemId !== undefined && value.solarSystemId !== null) {
const numericSystemId = decimalIdForReference(value.solarSystemId);
if (numericSystemId === null) reasons.push('unsafe_reference_id');
else {
const result = await readSystem(reader, numericSystemId, version);
system = result.reference;
if (!system || (result.row?.regionId !== null && !system.region)) reasons.push('reference_unknown');
}
}
return {
location: {
kind: 'structure', id: value.structureId, name: typeof value.name === 'string' ? value.name : null, parentId: null,
evidence: value.status === 'accessible' ? 'private_structure_access' : 'unknown',
sourceVerified: value.status === 'accessible' ? true : null,
observedAt: value.observedAt, expiresAt: value.expiresAt,
referenceVersion: version, dockingPermission: 'unknown', system, region: system?.region ?? null,
},
reasons,
};
}
/**
* Bounded location/containment enrichment. It returns a result even when
* portions are unavailable so T07 can stage and publish only when its own
* completeness policy permits. No result includes valuation or quantity
* multiplication; quantity remains the source asset quantity on each row.
*/
export class InventoryLocationService {
private readonly reference: LocationServiceOptions['reference'];
private readonly structures?: PrivateStructurePort;
private readonly now: () => number;
private readonly maxDepth: number | undefined;
constructor(options: LocationServiceOptions) {
this.reference = options.reference;
this.structures = options.structures;
this.now = options.now ?? (() => Date.now());
this.maxDepth = options.maxDepth;
}
async enrich(request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
const authority = validateAuthorityContext(request.authority);
if (!authority.scopes.includes(CHARACTER_ASSETS_SCOPE)) throw new TypeError('character asset scope is required for location enrichment');
if (typeof request.assetGeneration !== 'string' || !request.assetGeneration) throw new TypeError('asset generation is required');
if (!Array.isArray(request.assets)) throw new TypeError('assets must be an array');
for (const asset of request.assets) {
if (!asset || typeof asset !== 'object' || asset.characterId !== authority.characterId) throw new TypeError('asset character or shape is invalid');
}
if (request.names?.characterId !== undefined && request.names.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
if (request.locations?.characterId !== undefined && request.locations.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
for (const observation of request.namesBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
for (const observation of request.locationsBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
const now = finiteNow(this.now);
const namesBatches = request.namesBatches ?? (request.names ? [request.names] : []);
const locationsBatches = request.locationsBatches ?? (request.locations ? [request.locations] : []);
const namesPlan = normalizeObservationBatches(namesBatches, 'names', request.namesState, request.namesRequestedIds);
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', request.locationsState, request.locationsRequestedIds);
const containmentDepth = request.maxDepth ?? this.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
let pinned: PinnedReference | undefined;
try {
pinned = pinReference(this.reference);
const version = pinned.pinnedVersion;
const freshNameBatches = namesBatches.filter((observation) => (observation.state === undefined || observation.state === 'observed') && now >= observation.retrievedAt && now < observation.expiresAt);
const containment = resolveContainment({ assets: request.assets, characterId: authority.characterId, names: freshNameBatches.length > 0 ? nameMap(request.names, freshNameBatches) : undefined, typeNames: request.typeNames, maxDepth: containmentDepth });
const roots = new Map<string, RootOutcome>();
let referenceRootCount = 0;
for (const root of containment.roots) {
if (root.kind !== 'external') continue;
const key = `${root.locationType}:${root.id}`;
if (roots.has(key)) continue;
if (referenceRootCount >= MAX_REFERENCE_ROOTS) {
roots.set(key, { location: emptyUnknown(root.id, version), reasons: ['reference_unavailable'] });
continue;
}
referenceRootCount += 1;
roots.set(key, await this.resolveRoot(root.locationType, root.id, authority, version, pinned, now));
}
const referenceFacts: ReferenceLocationFactPin[] = [];
const seenReferenceFacts = new Set<string>();
const collectReferenceFacts = (location: LocationReference | null): void => {
if (!location || (location.kind !== 'station' && location.kind !== 'system' && location.kind !== 'region')) return;
const key = `${location.kind}:${location.id}`;
if (!seenReferenceFacts.has(key)) {
seenReferenceFacts.add(key);
referenceFacts.push({ kind: location.kind, id: location.id, name: location.name, parentId: location.parentId, evidence: location.evidence as 'verified' | 'system_listed', sourceVerified: location.sourceVerified === true, systemId: location.kind === 'station' ? location.parentId : location.system?.id ?? null, regionId: location.region?.id ?? null, referenceVersion: version });
}
collectReferenceFacts(location.system); collectReferenceFacts(location.region);
};
for (const outcome of roots.values()) collectReferenceFacts(outcome.location);
const structureObservations = [...roots.values()].flatMap((outcome) => outcome.structureObservation ? [{ structureId: outcome.structureObservation.structureId, characterId: outcome.structureObservation.characterId, status: outcome.structureObservation.status, evidence: outcome.structureObservation.evidence, name: outcome.structureObservation.name, solarSystemId: outcome.structureObservation.solarSystemId ?? null, ownerId: outcome.structureObservation.ownerId ?? null, typeId: outcome.structureObservation.typeId ?? null, observedAt: outcome.structureObservation.observedAt, expiresAt: outcome.structureObservation.expiresAt, referenceVersion: version }] : []);
const pin = createReferenceEnrichmentPin({
assetGeneration: request.assetGeneration, authority, referenceVersion: version, names: request.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined), namesBatches: namesBatches.length > 1 ? namesBatches : undefined, locations: request.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined), locationsBatches: locationsBatches.length > 1 ? locationsBatches : undefined,
namesState: namesPlan.state, locationsState: locationsPlan.state, namesRequestedIds: namesPlan.requestedIds, locationsRequestedIds: locationsPlan.requestedIds,
structureRevision: request.structureRevision, structureObservations, referenceFacts, containmentDepth,
});
const names = nameMap(request.names, namesBatches);
const positions = positionMap(request.locations, locationsBatches);
const requestedNames = new Set(namesPlan.requestedIds);
const requestedLocations = new Set(locationsPlan.requestedIds);
const nameObservations = observationMap(namesBatches);
const locationObservations = observationMap(locationsBatches);
const unresolved: LocationUnresolved[] = containment.unresolved.map((entry) => ({ itemId: entry.itemId, reasons: [...entry.reasons] }));
const items: EnrichedLocationNode[] = containment.nodes.map((node) => {
const requestedName = requestedNames.has(node.itemId);
const requestedLocation = requestedLocations.has(node.itemId);
const nameObservation = nameObservations.get(node.itemId) ?? request.names;
const locationObservation = locationObservations.get(node.itemId) ?? request.locations;
const nameStatus = helperStatus(nameObservation, requestedName, namesPlan.state, nameStatusFor, node.itemId, now);
const locationStatus = helperStatus(locationObservation, requestedLocation, locationsPlan.state, locationStatusFor, node.itemId, now);
const customName = nameStatus === 'resolved' ? (names.get(node.itemId) ?? null) : null;
const rootOutcome = node.root.kind === 'external' ? roots.get(`${node.root.locationType}:${node.root.id}`) : undefined;
const location = rootOutcome?.location ?? emptyUnknown(node.root.id ?? node.locationId, version);
const reasons = new Set<UnresolvedReason>([...node.unresolved]);
if (nameStatus === 'expired') reasons.add('name_expired');
if (nameStatus === 'forbidden') reasons.add('name_forbidden');
if (nameStatus === 'unavailable') reasons.add('name_unavailable');
if (locationStatus === 'expired') reasons.add('location_expired');
if (locationStatus === 'forbidden') reasons.add('location_forbidden');
if (locationStatus === 'unavailable') reasons.add('location_unavailable');
if (rootOutcome) for (const reason of rootOutcome.reasons) reasons.add(reason);
const mergedReasons = uniqueReasons([...reasons]);
if (mergedReasons.length > 0) unresolved.push({ itemId: node.itemId, reasons: mergedReasons });
return { ...node, unresolved: mergedReasons, customName, nameStatus, position: locationStatus === 'resolved' ? (positions.get(node.itemId) ?? null) : null, locationStatus, location };
});
// A root with no asset cannot make a row, but it is still part of the
// result's pinned location set for deterministic T09 filtering.
const resolvedRoots = [...roots.values()].map((value) => value.location).sort((left, right) => compareDecimalIds(left.id, right.id));
const uniqueUnresolved = new Map<string, { itemId: DecimalId | null; reasons: Set<UnresolvedReason> }>();
for (const entry of unresolved) {
const key = entry.itemId ?? '';
const current = uniqueUnresolved.get(key) ?? { itemId: entry.itemId, reasons: new Set<UnresolvedReason>() };
for (const reason of entry.reasons) current.reasons.add(reason);
uniqueUnresolved.set(key, current);
}
const finalUnresolved = [...uniqueUnresolved.values()].map((entry) => ({ itemId: entry.itemId, reasons: uniqueReasons([...entry.reasons]) })).sort((left, right) => (left.itemId && right.itemId ? compareDecimalIds(left.itemId, right.itemId) : (left.itemId ?? '').localeCompare(right.itemId ?? '')) || left.reasons.join(',').localeCompare(right.reasons.join(',')));
const complete = finalUnresolved.length === 0;
return { status: complete ? 'complete' : 'partial', complete, items, rows: items, roots: resolvedRoots, unresolved: finalUnresolved, inputPin: pin, enrichmentVersion: deterministicEnrichmentVersion(pin) };
} finally {
if (pinned && typeof pinned.close === 'function' && pinned !== this.reference) pinned.close();
}
}
private async resolveRoot(locationType: string, id: DecimalId, authority: InventoryLocationAuthority, version: string, reader: ReferenceReader, now: number): Promise<RootOutcome> {
const numericId = decimalIdForReference(id);
if (locationType === 'other') {
if (!this.structures) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
let observation: PrivateStructureAccessObservation;
try {
observation = await this.structures.access(privateContext(authority, version), id);
} catch (error) {
// Authentication/lease fences remain fatal; malformed optional helper
// responses become an explicit unavailable structure instead.
if (error instanceof EsiError && error instanceof EsiHttpError && error.status === 401) throw error;
if (error instanceof EsiError) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
throw error;
}
if ((observation.status !== 'accessible' && observation.status !== 'forbidden' && observation.status !== 'unavailable') || observation.structureId !== id || observation.characterId !== authority.characterId || observation.dockingPermission !== 'unknown' ||
(observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') ||
(observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') ||
(observation.status === 'unavailable' && observation.evidence !== 'unavailable') ||
(observation.status !== 'accessible' && observation.name !== null) ||
!Number.isFinite(observation.observedAt) || !Number.isFinite(observation.expiresAt) || observation.expiresAt <= now || observation.expiresAt < observation.observedAt) {
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
}
try {
if (observation.status === 'accessible') {
const result = await structureLocation(observation, version, reader);
return { ...result, structureObservation: observation };
}
const denied = await structureLocation(observation, version, reader);
return { location: denied.location, reasons: [observation.status === 'forbidden' ? 'forbidden_structure' : 'unavailable_structure'], structureObservation: observation };
} catch (error) {
if (error instanceof EsiError) throw error;
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
}
}
if (numericId === null) return { location: emptyUnknown(id, version), reasons: ['unsafe_reference_id'] };
try {
if (locationType === 'station') {
const location = await readStation(reader, numericId, version);
if (!location) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
const reasons: UnresolvedReason[] = [];
if (!location.system) reasons.push('reference_unknown');
else if (location.system.parentId !== null && !location.system.region) reasons.push('reference_unknown');
return { location, reasons };
}
if (locationType === 'solar_system') {
const result = await readSystem(reader, numericId, version);
if (!result.reference) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
const reasons: UnresolvedReason[] = [];
if (result.row?.regionId !== null && !result.reference.region) reasons.push('reference_unknown');
return { location: result.reference, reasons };
}
return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
} catch {
return { location: emptyUnknown(id, version), reasons: ['reference_unavailable'] };
}
}
}
export async function enrichInventoryLocations(options: LocationServiceOptions, request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
return new InventoryLocationService(options).enrich(request);
}
export const LocationService = InventoryLocationService;
export const enrichLocations = enrichInventoryLocations;
export const enrichInventoryLocation = enrichInventoryLocations;
export type { ReferenceEnrichmentPin };
@@ -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;
+1
View File
@@ -19,6 +19,7 @@ export const RELEASE_CAPABILITIES = [
'wallet_balance',
'inventory_export',
'manufacturing_opportunities',
'inventory_locations',
] as const;
export type ReleaseCapability = (typeof RELEASE_CAPABILITIES)[number];
+84 -3
View File
@@ -7,6 +7,7 @@ import type {
UserId,
SafeSyncJob,
} from "$lib/domain/model";
import type { EnrichedLocationNode, ReferenceEnrichmentPin } from "$lib/domain/inventory-location";
export type OAuthStateRecord = {
stateHash: string;
@@ -98,6 +99,24 @@ type AssetPage = {
isDone: boolean;
continueCursor: string;
generation: string | null;
enrichmentStatus: string | null;
enrichmentVersion: string | null;
snapshotChanged: boolean;
};
export type EnrichmentManifest = {
generation: string;
status: string | null;
version: string | null;
inputPin: ReferenceEnrichmentPin | null;
unresolvedCount: number | null;
};
export type EnrichmentChunkPage = {
chunks: { index: number; rows: EnrichedLocationNode[] }[];
isDone: boolean;
continueCursor: string;
generation: string | null;
status: string | null;
version: string | null;
};
const SNAPSHOT_CHANGED = "Convex asset snapshot changed during read";
@@ -112,6 +131,8 @@ const QUERY = {
syncJob: "functions:bridgeSyncJob",
cooldown: "functions:bridgeCooldown",
workerCooldown: "functions:bridgeSyncWorkerCooldown",
enrichmentManifest: "functions:bridgeEnrichmentManifest",
enrichmentChunks: "functions:bridgeEnrichmentChunksPage",
} as const;
const MUTATION = {
@@ -136,9 +157,12 @@ const MUTATION = {
refreshSyncJobAuthority: "functions:bridgeRefreshSyncJobAuthority",
restartSyncJobGeneration: "functions:bridgeRestartSyncJobGeneration",
stageSyncAssets: "functions:bridgeStageSyncAssets",
stageSyncEnrichment: "functions:bridgeStageSyncEnrichment",
resetSyncEnrichment: "functions:bridgeResetSyncEnrichment",
commitSyncJob: "functions:bridgeCommitSyncJob",
failSyncJob: "functions:bridgeFailSyncJob",
deferSyncJob: "functions:bridgeDeferSyncJob",
recordSyncCooldown: "functions:bridgeRecordSyncCooldown",
acquireRefreshLease: "functions:bridgeAcquireRefreshLease",
renewRefreshLease: "functions:bridgeRenewRefreshLease",
releaseRefreshLease: "functions:bridgeReleaseRefreshLease",
@@ -328,6 +352,8 @@ export class ConvexStore implements CharacterAssetRepository {
const assets: Asset[] = [];
let cursor: string | null = null;
let pinnedGeneration: string | null | undefined;
let pinnedEnrichmentStatus: string | null | undefined;
let pinnedEnrichmentVersion: string | null | undefined;
try {
// Each query reads bounded snapshot chunks. The guard prevents a
// malformed backend cursor from turning a request into an infinite loop.
@@ -341,9 +367,12 @@ export class ConvexStore implements CharacterAssetRepository {
args.expectedGeneration = pinnedGeneration;
}
const result: AssetPage = await this.query<AssetPage>(QUERY.assetsPage, args);
if (result.snapshotChanged) throw new Error(SNAPSHOT_CHANGED);
if (pinnedGeneration === undefined) {
pinnedGeneration = result.generation;
} else if (result.generation !== pinnedGeneration) {
pinnedEnrichmentStatus = result.enrichmentStatus;
pinnedEnrichmentVersion = result.enrichmentVersion;
} else if (result.generation !== pinnedGeneration || result.enrichmentStatus !== pinnedEnrichmentStatus || result.enrichmentVersion !== pinnedEnrichmentVersion) {
throw new Error(SNAPSHOT_CHANGED);
}
assets.push(...result.page);
@@ -400,8 +429,56 @@ export class ConvexStore implements CharacterAssetRepository {
await this.workerMutation(MUTATION.stageSyncAssets, { jobId, leaseToken, index, assets });
}
public async commitSyncJob(jobId: string, leaseToken: string, chunkCount: number, syncedAt: number, expiresAt: number): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.commitSyncJob, { jobId, leaseToken, chunkCount, syncedAt, expiresAt });
public async stageSyncEnrichment(jobId: string, leaseToken: string, index: number, rows: EnrichedLocationNode[]): Promise<void> {
await this.workerMutation(MUTATION.stageSyncEnrichment, { jobId, leaseToken, index, rows });
}
public async resetSyncEnrichment(jobId: string, leaseToken: string): Promise<void> {
await this.workerMutation(MUTATION.resetSyncEnrichment, { jobId, leaseToken });
}
public async commitSyncJob(jobId: string, leaseToken: string, chunkCount: number, syncedAt: number, expiresAt: number, enrichment?: { status: 'complete' | 'partial' | 'failed' | 'unavailable'; version?: string; inputPin?: ReferenceEnrichmentPin; chunkCount: number; unresolvedCount: number; error?: string }): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.commitSyncJob, {
jobId, leaseToken, chunkCount, syncedAt, expiresAt,
...(enrichment === undefined ? {} : {
enrichmentStatus: enrichment.status,
...(enrichment.version === undefined ? {} : { enrichmentVersion: enrichment.version }),
...(enrichment.inputPin === undefined ? {} : { enrichmentInputPin: enrichment.inputPin }),
enrichmentChunkCount: enrichment.chunkCount,
enrichmentUnresolvedCount: enrichment.unresolvedCount,
...(enrichment.error === undefined ? {} : { enrichmentError: enrichment.error }),
}),
});
}
public async enrichmentManifest(userId: string, characterId: number, expectedGeneration?: string): Promise<EnrichmentManifest | null> {
return await this.query<EnrichmentManifest | null>(QUERY.enrichmentManifest, { userId, characterId, ...(expectedGeneration === undefined ? {} : { expectedGeneration }) });
}
public async enrichmentChunks(userId: string, characterId: number, generation: string): Promise<EnrichedLocationNode[]> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const rows: EnrichedLocationNode[] = [];
let cursor: string | null = null;
let pinnedStatus: string | null | undefined;
let pinnedVersion: string | null | undefined;
try {
for (let page = 0; page < 100_000; page += 1) {
const result: EnrichmentChunkPage = await this.query<EnrichmentChunkPage>(QUERY.enrichmentChunks, { userId, characterId, expectedGeneration: generation, paginationOpts: { numItems: 8, cursor } });
if (result.generation !== generation) throw new Error(SNAPSHOT_CHANGED);
if (pinnedStatus === undefined) { pinnedStatus = result.status; pinnedVersion = result.version; }
else if (result.status !== pinnedStatus || result.version !== pinnedVersion) throw new Error(SNAPSHOT_CHANGED);
rows.push(...result.chunks.flatMap((chunk: { index: number; rows: EnrichedLocationNode[] }) => chunk.rows));
if (result.isDone) return rows;
if (result.continueCursor === cursor) throw new Error('Convex enrichment pagination did not advance');
cursor = result.continueCursor;
}
throw new Error('Convex enrichment pagination exceeded its safety bound');
} catch (error) {
if (error instanceof Error && error.message === SNAPSHOT_CHANGED && attempt < 2) continue;
throw error;
}
}
throw new Error(SNAPSHOT_CHANGED);
}
public async failSyncJob(jobId: string, leaseToken: string): Promise<boolean> {
@@ -416,6 +493,10 @@ export class ConvexStore implements CharacterAssetRepository {
return await this.workerQuery<{ until: number; group?: string; kind: string } | null>(QUERY.workerCooldown, { context, ...(group === undefined ? {} : { group }) });
}
public async recordSyncCooldown(context: string, until: number, group?: string): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.recordSyncCooldown, { context, until, ...(group === undefined ? {} : { group }) });
}
public async acquireRefreshLease(userId: string, characterId: number, ownerId: string, leaseMs?: number): Promise<RefreshLease | null> {
return await this.workerMutation<RefreshLease | null>(MUTATION.acquireRefreshLease, { userId, characterId, ownerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
}
+2 -1
View File
@@ -12,6 +12,7 @@ const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dat
const CONSENT_CAPABILITIES = new Set<Capability>([
'asset_search',
'asset_valuation',
'inventory_locations',
'wallet_balance',
'manufacturing_opportunities',
]);
@@ -33,7 +34,7 @@ export const POST: RequestHandler = async (event) => {
if (!CONSENT_CAPABILITIES.has(capabilityValue as Capability)) error(400, 'Unsupported consent capability');
const capability = capabilityValue as Capability;
// Domain capability and deployment release gate are separate controls.
if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
if ((capability === 'asset_search' || capability === 'asset_valuation' || capability === 'inventory_locations' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
&& !capabilityEnabled(capability, config.capabilityAllowlist)) {
error(503, capabilityDisabledReason(capability));
}
+2
View File
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async (event) => {
characters: [],
identityEnabled: false,
walletEnabled: false,
locationEnabled: false,
error: capabilityDisabledReason('identity'),
};
}
@@ -27,6 +28,7 @@ export const load: PageServerLoad = async (event) => {
characters: await charactersFor(current?.userId ?? null),
identityEnabled: true,
walletEnabled: capabilityEnabled('wallet_balance', config.capabilityAllowlist),
locationEnabled: capabilityEnabled('inventory_locations', config.capabilityAllowlist),
error,
};
};
+13
View File
@@ -2,6 +2,10 @@
import LiveCharacters from '$lib/components/LiveCharacters.svelte';
let { data } = $props();
const datasetLabel = (dataset: string) => dataset.replaceAll('_', ' ');
const assetsScope = 'esi-assets.read_assets.v1';
const structuresScope = 'esi-universe.read_structures.v1';
const locationState = (character: any) => character.authorizationStatus !== 'active' || !character.scopes.includes(assetsScope)
? 'reduced' : character.scopes.includes(structuresScope) ? 'granted' : 'missing';
</script>
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
<p class="muted">Connect each character explicitly. One primary character is the only signed-out login; linked characters are data-only until you switch the primary while authenticated.</p>
@@ -22,6 +26,7 @@
<p class="muted">Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}</p>
{/if}
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
<p class="muted">Inventory locations: <strong>{data.locationEnabled ? locationState(character) : 'unavailable'}</strong>{#if data.locationEnabled && locationState(character) === 'missing'} · Structure consent is required for private location evidence.{/if}</p>
{#if data.mode !== 'demo' && data.identityEnabled}
<div class="row">
{#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'}
@@ -33,6 +38,14 @@
<input type="hidden" name="targetCharacterId" value={character.id} />
<button class="quiet">{character.authorizationStatus === 'active' ? 'Refresh authorization' : 'Reconnect'}</button>
</form>
{#if character.authorizationStatus === 'active' && data.locationEnabled && character.scopes.includes(assetsScope) && !character.scopes.includes(structuresScope)}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="inventory_locations" />
<input type="hidden" name="intent" value="upgrade_dataset" />
<input type="hidden" name="targetCharacterId" value={character.id} />
<button class="quiet">Authorize private locations</button>
</form>
{/if}
{#if character.authorizationStatus === 'active' && data.walletEnabled}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="wallet_balance" />