From 0c9197f84167059322621459aeac5e26c1a5bed2 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 20 Sep 2026 12:13:51 -0400 Subject: [PATCH] feat: add pinned inventory location enrichment --- compose.yaml | 3 + docs/architecture.md | 2 +- docs/design-records/t08-inventory-location.md | 11 + docs/esi-operations.md | 18 +- docs/parallel-worktrees.md | 38 +- docs/roadmap.md | 8 +- docs/verification.md | 58 +- scripts/run-sync-worker.ts | 4 +- src/convex/functions.ts | 385 +++++++- src/convex/schema.ts | 41 + src/lib/domain/capabilities.ts | 8 +- src/lib/domain/inventory-location.ts | 909 ++++++++++++++++++ src/lib/domain/model.ts | 2 +- src/lib/server/esi/index.ts | 76 +- src/lib/server/inventory/containment.ts | 59 ++ src/lib/server/inventory/location-service.ts | 400 ++++++++ .../inventory/private-structure-cache.ts | 291 ++++++ src/lib/server/release-gate.ts | 1 + src/lib/server/store.ts | 87 +- src/routes/auth/eve/+server.ts | 3 +- src/routes/characters/+page.server.ts | 2 + src/routes/characters/+page.svelte | 13 + tests/contracts.test.ts | 4 + tests/esi.test.ts | 22 +- tests/fixtures/esi/transport-metadata.json | 12 + .../inventory/cycles-and-missing.json | 5 + tests/fixtures/inventory/nested-assets.json | 5 + tests/inventory-containment.test.ts | 67 ++ tests/inventory-location.test.ts | 233 +++++ tests/t07-sync.test.ts | 174 ++++ tests/t07-worker-runtime.test.ts | 15 +- workers/sync.ts | 215 ++++- 32 files changed, 3116 insertions(+), 55 deletions(-) create mode 100644 docs/design-records/t08-inventory-location.md create mode 100644 src/lib/domain/inventory-location.ts create mode 100644 src/lib/server/inventory/containment.ts create mode 100644 src/lib/server/inventory/location-service.ts create mode 100644 src/lib/server/inventory/private-structure-cache.ts create mode 100644 tests/fixtures/inventory/cycles-and-missing.json create mode 100644 tests/fixtures/inventory/nested-assets.json create mode 100644 tests/inventory-containment.test.ts create mode 100644 tests/inventory-location.test.ts diff --git a/compose.yaml b/compose.yaml index 532995a..c7d9f07 100644 --- a/compose.yaml +++ b/compose.yaml @@ -71,6 +71,9 @@ services: SYNC_WORKER_LEASE_MS: ${SYNC_WORKER_LEASE_MS:-600000} SYNC_WORKER_HEARTBEAT_MS: ${SYNC_WORKER_HEARTBEAT_MS:-60000} SYNC_WORKER_POLL_MS: ${SYNC_WORKER_POLL_MS:-1000} + SYNC_WORKER_ENRICHMENT_DEADLINE_MS: ${SYNC_WORKER_ENRICHMENT_DEADLINE_MS:-120000} + REFERENCE_DIR: /app/data/reference + volumes: ["reference-data:/app/data/reference:ro"] depends_on: backend: { condition: service_healthy } reference-worker: diff --git a/docs/architecture.md b/docs/architecture.md index dc4e3d8..ad14ed2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ Dedicated Bun reference worker EVE authorization-code + PKCE and encrypted token storage runs in Bun. First login creates an app user keyed to a verified character and owner claim; explicit linking attaches additional characters to the current user. Conflicting ownership never silently merges accounts. Session cookies carry random opaque values; only hashes are stored. Application RS256 JWTs are short-lived and audience-scoped for the Convex browser client. EVE access/refresh tokens are never browser credentials. -Asset sync is a durable `asset_sync` job owned by the authenticated user; `/api/sync` only enqueues a server-derived, deduplicated request. A dedicated Bun worker claims bounded due work with renewable tokenized leases, stages validated chunks, then atomically changes a character's active generation after all pages succeed. A failed fetch preserves the previous complete generation. Sync metadata records freshness/error, and the domain gate refuses to present missing/stale coverage as complete. ESI page caches are isolated by authorization identity; no partial paginated result is accepted as a full snapshot. Cooldowns are persisted in Convex and shared by workers. Token refresh is coordinated by a durable per-character lease and compare-and-swap; uncertain rotating-token results fail closed as `reconnect_required` instead of replaying a refresh. The worker receives no application signing, JEV, reference, or deployment-admin secrets. This is a local T07 prototype; it requires `CONVEX_SYNC_WORKER_SECRET` and the explicit worker-scoped startup path. T08 location/containment enrichment remains feature-local, with integration at the post-assets/pre-publication seam still pending. +Asset sync is a durable `asset_sync` job owned by the authenticated user; `/api/sync` only enqueues a server-derived, deduplicated request. A dedicated Bun worker claims bounded due work with renewable tokenized leases, stages validated chunks, then atomically changes a character's active generation after all pages succeed. A failed fetch preserves the previous complete generation. Sync metadata records freshness/error, and the domain gate refuses to present missing/stale coverage as complete. ESI page caches are isolated by authorization identity; no partial paginated result is accepted as a full snapshot. Cooldowns are persisted in Convex and shared by workers. Token refresh is coordinated by a durable per-character lease and compare-and-swap; uncertain rotating-token results fail closed as `reconnect_required` instead of replaying a refresh. The worker receives no application signing, JEV, reference, or deployment-admin secrets. This is a local T07/T08 prototype; it requires `CONVEX_SYNC_WORKER_SECRET`, the explicit worker-scoped startup path, and T08's read-only `REFERENCE_DIR` artifact mount. T08 location/containment enrichment is integrated at the post-assets/pre-publication seam and remains undeployed; no live Convex-capacity or production-readiness claim is made. ## Valuation diff --git a/docs/design-records/t08-inventory-location.md b/docs/design-records/t08-inventory-location.md new file mode 100644 index 0000000..02684df --- /dev/null +++ b/docs/design-records/t08-inventory-location.md @@ -0,0 +1,11 @@ +# T08 inventory location integration + +Status: implemented against T07 checkpoint `fe7d218195f8f501569c5a1e72f302733340be43`. + +- `GET /universe/structures/{structure_id}` is a fixed ESI operation pinned to `esi-universe.read_structures.v1` and the captured OpenAPI evidence in `docs/research/eve-openapi-evidence.json`. +- `inventory_query` remains assets-only. `inventory_locations` is an additive, explicit consent capability for structure evidence; existing grants are never silently expanded. +- Helper calls remain bounded to 1,000 IDs. Larger snapshots retain every response batch in the generation-level deterministic input pin; no cap is lifted. +- Location rows stage in private Convex chunks tied to the exact asset generation, authority counters/scopes, reference version, helper observations and deterministic enrichment version. Public station/system/region facts are included in the bounded pin with an internal consistency digest; that digest is not treated as external authenticity, and the pinned reference version remains the provenance boundary. Asset and enrichment manifests publish atomically. +- Partial/failed helper, reference and structure coverage is explicit. Raw assets remain usable, but only `complete` enrichment is complete; 403/404 structures remain unresolved and docking permission is always `unknown`. The initial optional-enrichment persistence prototype is conservatively capped at 2,500 rows and 16 MiB server-side; the worker preflights deterministic chunks at 192 KiB (below the 256 KiB conservative envelope target) and an aggregate 2 MiB budget before staging. Larger or oversized single-row enrichments publish raw assets with a truthful failed enrichment instead of attempting an unmeasured near-50k transaction. +- The sync worker receives only `REFERENCE_DIR` and a read-only reference-data mount. It opens an immutable pinned reader and has no reference import/write credentials. The mount must contain a compatible production-profile manifest/artifact; an invalid, missing, or fixture-profile artifact is treated as honest reference unavailability and raw assets still publish without enrichment. Host archives are not deployment volumes and must not be promoted as a deployed reference artifact. +- T09 projection, filtering and cursor behavior remains unimplemented by design; T09 consumes the generation-matching manifest/chunk seam. diff --git a/docs/esi-operations.md b/docs/esi-operations.md index 89e8653..4573b4b 100644 --- a/docs/esi-operations.md +++ b/docs/esi-operations.md @@ -4,8 +4,9 @@ The server ESI adapter uses the fixed trusted base `https://esi.evetech.net` and `X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes a fixed allowlist of typed safe reads: character assets (paged GET), character asset names and locations (bounded read-only POST), character wallet balance (unpaged GET), and -global market prices (unpaged GET). Callers cannot provide an arbitrary ESI -URL, host, method, or POST body. +global market prices (unpaged GET), and character-scoped private structure +resolution (fixed GET). Callers cannot provide an arbitrary ESI URL, host, method, +or POST body. ## Asset synchronization @@ -84,6 +85,19 @@ JavaScript literals. Asset helper metadata cites pinned OpenAPI evidence; wallet metadata cites the current official OpenAPI retrieval and explicitly records that it is not an immutable historical capture. +## Private structure resolution + +`EsiClient.getStructure` calls the pinned `GET /universe/structures/{structure_id}` +operation with `esi-universe.read_structures.v1`. The route and scope are pinned in +`docs/research/eve.md` and the captured OpenAPI evidence: a 200 is character-scoped +ESI access evidence, while 403 and 404 remain explicit unresolved outcomes. IDs are +validated and serialized as decimal text without `Number` coercion. The response +retains the optional name, solar-system, owner/type IDs and finite position with +retrieval/expiry provenance. The T08 cache includes the complete authority and +reference version; it never stores tokens and never derives ACL membership or +docking permission (`unknown` is the only permission value). Deadlines, rate limits, +and transport failures remain typed errors for T07 retry/cooldown handling. + ## Wallet balance `EsiClient.getWalletBalance` reads the unpaged character wallet route with the diff --git a/docs/parallel-worktrees.md b/docs/parallel-worktrees.md index ad66aac..f8b8b74 100644 --- a/docs/parallel-worktrees.md +++ b/docs/parallel-worktrees.md @@ -8,17 +8,17 @@ **Planning baseline:** `fdcc486` (`Initial Commit`). The roadmap records an earlier 97-test gate; each prototype change reruns the current local suite. Existing `jobs`, `userPreferences` and `referenceSyncMetadata` tables are placeholders, not services. -**T06/T07 evidence:** The centralized ESI client has fixed typed asset, asset-name, -asset-location, wallet-balance, and market-read strategies with bounded response -bytes/rows/pages, bounded retries, abort-first cleanup, validators, canonical int64 -helper bodies, complete scoped authority-context cache identity, cooldown queries, -and runtime schemas. T07 durable scheduling/worker orchestration is implemented as -a local prototype: the current full suite records 204 passing tests, including the -bundled dedicated worker, durable leases, fencing, cancellation/recovery, refresh -coordination and restricted startup. T08 location/service orchestration and T13 -wallet persistence/UI/service orchestration remain pending; T07/T08 integration is -not complete and production-readiness gates remain deferred. No deployed/live ESI -claim is made here. The narrow `beginSync` expected-authority fence is part of T06's +**T06/T07/T08 evidence:** The centralized ESI client has fixed typed asset, +asset-name, asset-location, wallet-balance, and market-read strategies with bounded +response bytes/rows/pages, bounded retries, abort-first cleanup, validators, +canonical int64 helper bodies, complete scoped authority-context cache identity, +cooldown queries, and runtime schemas. T07 durable scheduling/worker orchestration +and T08 location/containment enrichment are implemented as local prototypes. The +combined suite currently records 254 passing tests; that count includes parallel-core +T09/T13 tests and is not a T08-only count. T08 is implemented and undeployed; +production/live ESI/live Convex-capacity evidence remains deferred. T09 inventory +projection/query and T13 wallet work remain separate roadmap tasks with no +completion claim here. The narrow `beginSync` expected-authority fence is part of T06's atomic bridge contract: it prevents transport cache authority from being published under a different T05 grant/revision/credential fence; it is not T07 durable worker orchestration. @@ -147,20 +147,26 @@ Implement the prototype identity enforcement needed by the pipeline: revalidate **Deliver:** enqueue/status instead of long-running request work; typed deduplicated jobs, bounded attempts/due times, cancellation, fair scheduling/backpressure, shared cooldown/error budgets, renewable leases and fenced success **and failure** publication. Stage/validate assets before active-generation publication and retain validated `isSingleton` facts throughout ingestion. -Share a per-character refresh lease across web and workers, retaining token CAS. At-least-once delivery is not exactly-once remote refresh: an uncertain rotated result produces actionable reauthorization instead of automatic replay. Keep reference extraction separate and minimize worker secrets. Public feeds must be coordinated rather than independently refreshed per process. The worker requires the distinct `CONVEX_SYNC_WORKER_SECRET`; standalone startup uses a worker-only environment and Compose startup uses the explicit `worker` profile. +Share a per-character refresh lease across web and workers, retaining token CAS. At-least-once delivery is not exactly-once remote refresh: an uncertain rotated result produces actionable reauthorization instead of automatic replay. Keep reference extraction separate and minimize worker secrets. Public feeds must be coordinated rather than independently refreshed per process. The worker requires the distinct `CONVEX_SYNC_WORKER_SECRET`; standalone startup uses a worker-only environment and Compose startup uses the explicit `worker` profile. T08 adds only `REFERENCE_DIR` and a read-only reference-data volume to this environment; the worker opens an immutable artifact reader and has no import/write credentials. -**Verified locally:** process kill/restart, long paging beyond the old five-minute lease, duplicate delivery, cancellation, scope loss, stale-worker completion/failure, refresh races, uncertain rotation, restricted runtime startup and configuration boundaries. The full suite currently records 204 passing tests. T08 enrichment integration at the post-assets/pre-publication seam remains pending; production migration/release evidence remains deferred. +**Verified locally:** process kill/restart, long paging beyond the old five-minute lease, duplicate delivery, cancellation, scope loss, stale-worker completion/failure, refresh races, uncertain rotation, restricted runtime startup and configuration boundaries. T08 enrichment is staged and atomically published at the post-assets/pre-publication seam; production migration/release evidence remains deferred. ### T08 — Private locations, custom names and containment -**Own:** proposed location/containment modules, feature-local authorization-context caches and tests. Consume T03 readers/T06 adapters; no second reference compiler or transport. +**Status:** implemented local prototype; undeployed. No live ESI, live Convex-capacity, production-reference, or production-readiness claim. + +**Checkpoint manifest:** see [the exact T08 manifest in the verification record](verification.md#exact-t08-checkpoint-manifest). T09 projection/query files/tests/fixtures and T13 wallet files/tests/fixtures are explicitly excluded. + +**Own:** location/containment modules, feature-local authorization-context caches, typed ESI structure adapter, incremental consent wiring, generation enrichment persistence and tests. Consume T03 readers; the shared ESI adapter is extended only with the fixed structure operation. **Deliver:** parent/item relationships, fitted flags, breadcrumb inputs and unresolved reasons. Resolve NPC stations from reference facts and private structures/custom names only under valid character scope/evidence. Integrate bounded enrichment with dataset freshness and revision invalidation; enrichment failures must remain explicit and cannot authorize or fabricate a complete location result. -**Done:** nested/fitted items, identical names, cycles, missing parents, stale/forbidden structures and large IDs pass tests. No numeric-ID guessing, hull/contents multiplier or cross-character private-name leakage. Provide versioned/pinned enrichment inputs for T09, not mutable names that silently change a cursor's filter results. +**Done locally:** nested/fitted items, identical names, cycles, missing parents, stale/forbidden structures, large IDs, multi-batch helper observations and 50k bounded chains pass synthetic tests. The worker stages private rows against the exact asset generation and authority/reference pin, publishes partial coverage honestly, and exposes generation-matching manifest/chunk reads. Public/private source facts are pinned and validated against persisted rows; oversized optional enrichment falls back before staging. No numeric-ID guessing, hull/contents multiplier, docking inference or cross-character private-name leakage. T09 receives versioned/pinned enrichment inputs rather than mutable names. The limits are conservative prototype bounds, not measured live Convex capacity. ### T09 — Inventory projections and query service +**Status:** separate accepted core task; not claimed complete by this T08 checkpoint. Its projection/query files, tests and fixture are excluded from the T08 manifest. + **Own:** generation-bound indexed inventory rows, inventory query service/repository and dedicated query tests; additive schema/store changes coordinated with T13. **Deliver:** staged projections published with validated generations; validated filters for character/type/custom name/category/group/quantity/location/system/region/pricing/BPC state; stable sorting and server-side paging. Pin selected input generations, location enrichment, reference/price versions and authorization revisions. Reuse or extend `AssetService` authorization/valuation rather than cloning it. @@ -199,6 +205,8 @@ Compute selected summaries across **all matching rows**, not the visible page, w ### T13 — Wallet balance vertical slice +**Status:** separate accepted task; not claimed complete by this T08 checkpoint. All wallet files, tests and fixtures are excluded from the T08 manifest. + **Own:** new wallet domain/service/ESI-adapter orchestration, balance snapshot storage, wallet job handler, compact finance component/route and tests. Central schema/capability/application/assistant registrations land serially with other feature tasks. **Deliver:** opt-in balance capability using T05 consent/T07 jobs, exact signed cents from T04 and independently timestamped balance coverage. Ship a deterministic balance service, conventional UI and typed assistant/manual query adapter. Keep the wallet amount a **separately labelled component**, not a combined wealth total. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c4b11d..5dfe859 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # Implementation roadmap **Status:** planning proposal, not implementation or a public-launch declaration. -**Baseline:** prior checkpoints historically recorded live sign-in, asset synchronization and matching Assets/chat reference valuations for one character (see [verification](verification.md)); that history is not new T07 evidence. This T07 checkpoint records a local, disposable prototype only, with no new deployed/live ESI or production-readiness claim. T07 durable synchronization is implemented and verified locally with the current 204-test suite. T08 location/containment enrichment remains a separate feature whose post-assets/pre-publication integration is still pending. +**Baseline:** prior checkpoints historically recorded live sign-in, asset synchronization and matching Assets/chat reference valuations for one character (see [verification](verification.md)); that history is not new T08 evidence. This T08 checkpoint records a local, disposable prototype only, with no deployed/live ESI, live Convex-capacity, or production-readiness claim. T07 durable synchronization and T08 location/containment enrichment are implemented and verified locally. The current 254-test count is a combined suite including parallel-core T09/T13 tests, not a T08-only count. T09 projection/query and T13 wallet completion remain separate roadmap work. **Evidence:** [architecture](architecture.md), [milestone 1](milestone.md), [verification](verification.md), [ESI research](research/eve.md), [expansion data/permission matrix](research/expansion-data-contracts.md), and [reference operations](reference-operations.md). ## 1. Decisions confirmed for this roadmap @@ -16,9 +16,9 @@ New service/table/path names below are proposed implementation boundaries, not existing APIs. Later phases need focused design and current contract verification before coding; they are not one large implementation task. -### T07 prototype checkpoint +### T07/T08 prototype checkpoint -T07 is implemented as a local prototype, not a deployment claim. It requires the distinct `CONVEX_SYNC_WORKER_SECRET` in Convex and the worker environment, alongside `TOKEN_ENCRYPTION_KEY`, EVE client credentials and `ESI_USER_AGENT`. Start Compose workers only through the explicit `worker` profile; standalone development uses a worker-only `.env.worker`. T08 core modules and their integration at the post-assets/pre-publication seam remain pending. +T07 durable synchronization and T08 location/containment enrichment are implemented as local prototypes, not deployment claims. T07 requires the distinct `CONVEX_SYNC_WORKER_SECRET` in Convex and the worker environment, alongside `TOKEN_ENCRYPTION_KEY`, EVE client credentials and `ESI_USER_AGENT`. T08 adds the read-only `REFERENCE_DIR` artifact mount and generation-pinned enrichment seam. Start Compose workers only through the explicit `worker` profile; standalone development uses a worker-only `.env.worker`. T09 projection/query and T13 wallet remain separate tasks; passing the combined local suite does not claim either task complete. ## 2. What exists versus what still needs building @@ -28,7 +28,7 @@ T07 is implemented as a local prototype, not a deployment claim. It requires the | Assets | Complete staged snapshots, tenant gates, exact reference totals | Real search/filtering, paging, containment, custom names, meaningful locations, history | | References | 53,001 types; recipes, dogma and system/gate tables; immutable publication | Runtime reader exposes only types/version; station table currently empty; search and domain-specific readers missing | | Prices | Global ESI average prices, held by the web process | Durable public feed coordination, regional/private books, history, depth allocation, fees | -| Sync | T07 durable queued jobs, dedicated worker, leases, fenced generation publication, cancellation/recovery and shared refresh coordination | T08 enrichment integration and production release evidence remain pending | +| Sync | T07 durable queued jobs, dedicated worker, leases, fenced generation publication, cancellation/recovery and shared refresh coordination; T08 generation-pinned location enrichment | T09 projection/query integration and production release evidence remain pending | | Finances | Signed integer-cent money helpers | Wallets, journals, transactions, own orders, history/coverage and reconciliation | | Industry | Recipe data plus placeholder interfaces/economics fields | Owned blueprint instances, skills/jobs, facility assumptions, formulas and optimizer | | Navigation | Exact light-year distance helper and universe data | Gate-distance service, ship-aware capital graph, dynamic constraints and qualified access evidence | diff --git a/docs/verification.md b/docs/verification.md index ded3640..602403e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,6 +1,6 @@ # Verification record -This record retains historical verification facts from earlier checkpoints. The current T07 checkpoint is a local/disposable prototype only: it makes no new deployed, live-ESI, or production-readiness claim. Dependency versions are pinned in `package.json`/`bun.lock`. +This record retains historical verification facts from earlier checkpoints. The current T08 checkpoint is a local/disposable prototype only: it makes no deployed, live-ESI, live-Convex-capacity, or production-readiness claim. Dependency versions are pinned in `package.json`/`bun.lock`. T09 query/projection and T13 wallet work are not completion claims in this checkpoint. ## Initial offline/disposable-backend milestone @@ -40,14 +40,14 @@ A subsequent operator test exposed `ERR_INVALID_RESPONSE` when submitting Connec Historical audit regressions covered native-form/CSRF boundaries, shared artifact-directory precedence, live readers rejecting fixture profiles, offline no-network behavior, archive receipts/replay, ZIP/TAR checksums and limits, duplicate records/JSON keys, Hoboleaks metadata, and immutable cache provenance/rollback/reuse. See [live setup](live-setup.md) and [reference operations](reference-operations.md). -## T07 durable worker boundary regressions +## Historical T07 durable worker boundary regressions - Full local `bun test`: **204 passed, 0 failed** across 27 files. - `bun test tests/t07-sync.test.ts tests/t07-worker-runtime.test.ts tests/t07-route-boundaries.test.ts`: deterministic queue/fencing/retry regressions plus runtime alias/startup, missing-job/AbortSignal polling, HTTP 429/503 structured outcomes, forbidden worker-environment, hostile-origin, session-ordering and bounded-body checks (31 passed in the focused run). - `bun build workers/sync.ts --target bun`: produces the same alias-resolved worker artifact copied by the Docker build to `build/sync-worker.js`. A no-cache Docker image build completed, and `docker run --rm --network none` started the image's bundled worker through `scripts/run-sync-worker.ts` with inert Convex/EVE values and `SYNC_WORKER_STARTUP_CHECK=1`; no live keys or network calls were used. `scripts/run-sync-worker.ts` is separately tested to reject general bridge/admin/JWT/JEV credentials without echoing them. - The operational startup is `bun run secrets:run --worker -- docker compose --profile worker up -d --build sync-worker`; `--deployment`/`--configure` is reserved for Convex administration and configuration, not worker startup. Plain `docker compose up` leaves the worker stopped. -### T07 prototype stage manifest +### Historical T07 prototype stage manifest (not the current T08 manifest) The T07 stage includes the following files. T08 core files are intentionally excluded: @@ -92,6 +92,58 @@ workers/sync.ts Excluded T08 core files: `src/lib/domain/inventory-location.ts`, `src/lib/server/inventory/`, `tests/fixtures/inventory/`, `tests/inventory-containment.test.ts`, and `tests/inventory-location.test.ts`. +## T08 local checkpoint + +**Status:** implemented locally and undeployed. No live credentials, deployed endpoint, production volume, or production reference artifact was used. + +- `bun test`: **254 passed, 0 failed** across 29 files. This is the combined local suite and includes parallel-core T09/T13 tests; it is not a T08-only count. +- Focused T08/T07 regression run: `bun test tests/t07-sync.test.ts tests/inventory-location.test.ts tests/t07-worker-runtime.test.ts` — 54 passed, 0 failed. +- `bunx tsc --noEmit`, `bun run check`, and `git diff --check` passed. +- `bun run build` passed with the known non-fatal unresolved `bun:sqlite` external warning. +- The 50k containment/enrichment checks and 2,500-row persistence check are synthetic local prototype validations, not measured live Convex capacity. Optional enrichment is conservatively capped before staging; larger/oversized payloads preserve raw assets and publish failed optional enrichment. +- Real-query generation rollover, mixed helper batches, public/private fact forgery, forbidden structures, missing-parent/reference-unknown rows, and oversized worker envelopes have regression coverage. + +### Exact T08 checkpoint manifest + +The following files comprise the T08 implementation/checkpoint, including additive shared integration files: + +```text +compose.yaml +docs/architecture.md +docs/design-records/t08-inventory-location.md +docs/esi-operations.md +docs/parallel-worktrees.md +docs/roadmap.md +docs/verification.md +scripts/run-sync-worker.ts +src/convex/functions.ts +src/convex/schema.ts +src/lib/domain/capabilities.ts +src/lib/domain/inventory-location.ts +src/lib/domain/model.ts +src/lib/server/esi/index.ts +src/lib/server/inventory/containment.ts +src/lib/server/inventory/location-service.ts +src/lib/server/inventory/private-structure-cache.ts +src/lib/server/release-gate.ts +src/lib/server/store.ts +src/routes/auth/eve/+server.ts +src/routes/characters/+page.server.ts +src/routes/characters/+page.svelte +tests/contracts.test.ts +tests/esi.test.ts +tests/fixtures/esi/transport-metadata.json +tests/fixtures/inventory/cycles-and-missing.json +tests/fixtures/inventory/nested-assets.json +tests/inventory-containment.test.ts +tests/inventory-location.test.ts +tests/t07-sync.test.ts +tests/t07-worker-runtime.test.ts +workers/sync.ts +``` + +Explicitly excluded from this T08 manifest: accepted T09 core projection/query files `src/lib/server/inventory/projection.ts`, `src/lib/server/inventory/query-repository.ts`, `src/lib/server/inventory/query-service.ts`, `tests/inventory-query.test.ts`, and `tests/fixtures/inventory/query-assets.json`; all T13 wallet files/tests/fixtures under `src/lib/server/wallet/`, `tests/wallet-balance.test.ts`, `tests/fixtures/wallet/`, and `tests/fixtures/esi/wallet-balance-signed.json`. + ## Optional disposable Convex smoke (not part of this checkpoint) Use a **disposable** backend, set its `CONVEX_SELF_HOSTED_URL` and admin key, generate application keys and configure/deploy as in the README, then run: diff --git a/scripts/run-sync-worker.ts b/scripts/run-sync-worker.ts index 04594ba..0eb4e08 100644 --- a/scripts/run-sync-worker.ts +++ b/scripts/run-sync-worker.ts @@ -6,8 +6,8 @@ const forbidden = ['CONVEX_BRIDGE_SECRET', 'CONVEX_SELF_HOSTED_ADMIN_KEY', 'APP_ const workerEnvironmentKeys = [ 'PATH', 'HOME', 'TMPDIR', 'NODE_ENV', 'LANG', 'LC_ALL', 'TZ', 'NODE_EXTRA_CA_CERTS', 'CONVEX_URL', 'CONVEX_SYNC_WORKER_SECRET', 'TOKEN_ENCRYPTION_KEY', - 'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'EVE_CALLBACK_URL', 'ESI_USER_AGENT', - 'SYNC_WORKER_ID', 'SYNC_WORKER_LEASE_MS', 'SYNC_WORKER_HEARTBEAT_MS', 'SYNC_WORKER_POLL_MS', 'SYNC_WORKER_STARTUP_CHECK', + 'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'EVE_CALLBACK_URL', 'ESI_USER_AGENT', 'REFERENCE_DIR', + 'SYNC_WORKER_ID', 'SYNC_WORKER_LEASE_MS', 'SYNC_WORKER_HEARTBEAT_MS', 'SYNC_WORKER_POLL_MS', 'SYNC_WORKER_ENRICHMENT_DEADLINE_MS', 'SYNC_WORKER_STARTUP_CHECK', ] as const; export function workerEnvironment(base: Record): Record { return Object.fromEntries(workerEnvironmentKeys.flatMap((key) => base[key] === undefined ? [] : [[key, base[key] as string]])); diff --git a/src/convex/functions.ts b/src/convex/functions.ts index c47bba9..fe14e63 100644 --- a/src/convex/functions.ts +++ b/src/convex/functions.ts @@ -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(); + 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(); + 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> { + 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(); + 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, 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(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 = { 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, authority: any): Promise<{ rows: any[]; ids: Set }> { + 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(); + 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(); + const namesRequested = new Set(pin.names.requestedIds); + const namesResolved = new Set(pin.names.resolvedIds); + const namesMissing = new Set(pin.names.missingIds); + const namesBatchStates = new Map((pin.names.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string]))); + const locationsRequested = new Set(pin.locations.requestedIds); + const locationsResolved = new Set(pin.locations.resolvedIds); + const locationsMissing = new Set(pin.locations.missingIds); + const locationsBatchStates = new Map((pin.locations.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string]))); + const structurePins = new Map((pin.structures ?? []).map((observation: any) => [observation.structureId, observation])); + const referenceFacts = new Map((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(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) => { diff --git a/src/convex/schema.ts b/src/convex/schema.ts index 757f849..5ce162a 100644 --- a/src/convex/schema.ts +++ b/src/convex/schema.ts @@ -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(), diff --git a/src/lib/domain/capabilities.ts b/src/lib/domain/capabilities.ts index 7d46c7c..caf2b41 100644 --- a/src/lib/domain/capabilities.ts +++ b/src/lib/domain/capabilities.ts @@ -26,11 +26,14 @@ export const CAPABILITY_REQUIREMENTS: Readonly> = { reference: ['reference_data'], diff --git a/src/lib/domain/inventory-location.ts b/src/lib/domain/inventory-location.ts new file mode 100644 index 0000000..5d9f1e9 --- /dev/null +++ b/src/lib/domain/inventory-location.ts @@ -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 " 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 | Readonly>; + typeNames?: ReadonlyMap | Readonly>; + 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 & { + /** 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; + 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; + 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; + 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; + 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(map: ReadonlyMap | Readonly> | undefined, key: string): T | undefined { + if (!map) return undefined; + if (map instanceof Map) return map.get(key); + return (map as Readonly>)[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>)[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; + 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; + 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).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(); + const byId = new Map(); + 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 }; + type Frame = { id: DecimalId; asset: Asset }; + const computed = new Map(); + 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(); + 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(); + 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 : {}; + 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(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; + 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; + 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(); const resolvedUnion = new Set(); const missingUnion = new Set(); + 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(); + 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; + 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'; +} diff --git a/src/lib/domain/model.ts b/src/lib/domain/model.ts index 23dcd45..1da921e 100644 --- a/src/lib/domain/model.ts +++ b/src/lib/domain/model.ts @@ -84,7 +84,7 @@ export interface MarketEstimates { estimates(typeIds: number[]): Promise; } 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 = diff --git a/src/lib/server/esi/index.ts b/src/lib/server/esi/index.ts index 957707e..7367748 100644 --- a/src/lib/server/esi/index.ts +++ b/src/lib/server/esi/index.ts @@ -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 { + 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 { + 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, diff --git a/src/lib/server/inventory/containment.ts b/src/lib/server/inventory/containment.ts new file mode 100644 index 0000000..a697a96 --- /dev/null +++ b/src/lib/server/inventory/containment.ts @@ -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): 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 }); +} diff --git a/src/lib/server/inventory/location-service.ts b/src/lib/server/inventory/location-service.ts new file mode 100644 index 0000000..93108d5 --- /dev/null +++ b/src/lib/server/inventory/location-service.ts @@ -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 | Readonly>; + 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 { + const result = new Map(); + 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 { + const result = new Map(); + 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(observations: readonly T[]): Map { + const result = new Map(); + 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( + 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 & Partial; + 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 { + 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 { + 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 { + 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(); + 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(); + 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([...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 }>(); + for (const entry of unresolved) { + const key = entry.itemId ?? ''; + const current = uniqueUnresolved.get(key) ?? { itemId: entry.itemId, reasons: new Set() }; + 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 { + 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 { + return new InventoryLocationService(options).enrich(request); +} + +export const LocationService = InventoryLocationService; +export const enrichLocations = enrichInventoryLocations; +export const enrichInventoryLocation = enrichInventoryLocations; +export type { ReferenceEnrichmentPin }; diff --git a/src/lib/server/inventory/private-structure-cache.ts b/src/lib/server/inventory/private-structure-cache.ts new file mode 100644 index 0000000..e16fb87 --- /dev/null +++ b/src/lib/server/inventory/private-structure-cache.ts @@ -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; +export interface PrivateStructurePort { + access(context: PrivateStructureContext, structureId: DecimalId): Promise; +} +export type PrivateStructureCacheOptions = { + now?: () => number; + maxEntries?: number; + maxBytes?: number; +}; + +const DEFAULT_MAX_ENTRIES = 128; +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; +const MAX_NAME_LENGTH = 512; +const MAX_REASON_LENGTH = 256; + +function nowOf(clock: () => number): number { + const value = clock(); + if (!Number.isFinite(value)) throw new RangeError('cache clock must return a finite timestamp'); + return value; +} +function validCounter(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } + +export function validatePrivateStructureContext(value: unknown): PrivateStructureContext { + if (!value || typeof value !== 'object') throw new TypeError('private structure context is required'); + const candidate = value as Partial; + const authority = validateAuthorityContext(candidate); + if (typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion) throw new TypeError('reference version is required for private structure access'); + return { ...authority, referenceVersion: candidate.referenceVersion }; +} + +export function privateStructureCacheKey(context: PrivateStructureContext, structureId: DecimalId): string { + const valid = validatePrivateStructureContext(context); + assertDecimalId(structureId, 'structure ID'); + return stableSerialize({ + userId: valid.userId, + principal: valid.principal, + characterId: valid.characterId, + scopes: [...valid.scopes].sort(), + grantRevision: valid.grantRevision, + authorizationEpoch: valid.authorizationEpoch, + credentialVersion: valid.credentialVersion, + referenceVersion: valid.referenceVersion, + structureId, + }); +} + +function validateObservation(value: unknown, context: PrivateStructureContext, structureId: DecimalId, now: number): PrivateStructureAccessObservation { + if (!value || typeof value !== 'object') throw new TypeError('private structure reader returned invalid data'); + const candidate = value as Partial; + assertDecimalId(candidate.structureId, 'structure observation ID'); + if (candidate.structureId !== structureId) throw new TypeError('private structure observation ID does not match request'); + if (candidate.characterId !== context.characterId) throw new TypeError('private structure observation character mismatch'); + if (candidate.status !== 'accessible' && candidate.status !== 'forbidden' && candidate.status !== 'unavailable') throw new TypeError('private structure observation status is invalid'); + if (candidate.evidence !== 'esi_structure_access' && candidate.evidence !== 'esi_forbidden' && candidate.evidence !== 'unavailable') throw new TypeError('private structure observation evidence is invalid'); + if (candidate.status === 'accessible' && candidate.evidence !== 'esi_structure_access') throw new TypeError('accessible structure observation has invalid evidence'); + if (candidate.status === 'forbidden' && candidate.evidence !== 'esi_forbidden') throw new TypeError('forbidden structure observation has invalid evidence'); + if (candidate.status === 'unavailable' && candidate.evidence !== 'unavailable') throw new TypeError('unavailable structure observation has invalid evidence'); + if (candidate.dockingPermission !== 'unknown') throw new TypeError('structure access cannot assert docking permission'); + if (typeof candidate.name !== 'string' && candidate.name !== null) throw new TypeError('private structure name is invalid'); + if (typeof candidate.name === 'string' && (candidate.name.length > MAX_NAME_LENGTH || !candidate.name.trim())) throw new TypeError('private structure name is invalid'); + if (candidate.status !== 'accessible' && candidate.name !== null) throw new TypeError('private structure denial cannot carry a private name'); + const observedAt = candidate.observedAt; + const expiresAt = candidate.expiresAt; + if (typeof observedAt !== 'number' || typeof expiresAt !== 'number' || !Number.isFinite(observedAt) || !Number.isFinite(expiresAt) || expiresAt < observedAt) throw new TypeError('private structure freshness is invalid'); + if (expiresAt <= now) throw new TypeError('expired private structure observations are not cacheable'); + if (candidate.reason !== undefined && (typeof candidate.reason !== 'string' || candidate.reason.length > MAX_REASON_LENGTH)) throw new TypeError('private structure reason is invalid'); + if (candidate.revision !== undefined && (typeof candidate.revision !== 'string' || !candidate.revision)) throw new TypeError('private structure revision is invalid'); + for (const [field, value] of [['solarSystemId', candidate.solarSystemId], ['ownerId', candidate.ownerId], ['typeId', candidate.typeId] ] as const) { + if (value !== undefined && value !== null) assertDecimalId(value, field); + } + if (candidate.position !== undefined && candidate.position !== null) { + if (typeof candidate.position !== 'object' || !Number.isFinite(candidate.position.x) || !Number.isFinite(candidate.position.y) || !Number.isFinite(candidate.position.z)) throw new TypeError('private structure position is invalid'); + } + if (candidate.status !== 'accessible' && (candidate.solarSystemId !== undefined || candidate.ownerId !== undefined || candidate.typeId !== undefined || candidate.position !== undefined)) throw new TypeError('private structure denial cannot carry private facts'); + return { + structureId, + characterId: context.characterId, + status: candidate.status, + evidence: candidate.evidence, + name: candidate.name ?? null, + observedAt, + expiresAt, + ...(candidate.revision === undefined ? {} : { revision: candidate.revision }), + dockingPermission: 'unknown', + ...(candidate.reason === undefined ? {} : { reason: candidate.reason }), + ...(candidate.solarSystemId === undefined ? {} : { solarSystemId: candidate.solarSystemId }), + ...(candidate.ownerId === undefined ? {} : { ownerId: candidate.ownerId }), + ...(candidate.typeId === undefined ? {} : { typeId: candidate.typeId }), + ...(candidate.position === undefined ? {} : { position: candidate.position === null ? null : { ...candidate.position } }), + }; +} + +type Entry = { key: string; value: PrivateStructureAccessObservation; bytes: number; touchedAt: number }; + +/** Bounded, authority-isolated process cache for private structure observations. */ +export class PrivateStructureCache { + private readonly entries = new Map(); + private bytes = 0; + private readonly now: () => number; + private readonly maxEntries: number; + private readonly maxBytes: number; + + constructor(options: PrivateStructureCacheOptions = {}) { + this.now = options.now ?? (() => Date.now()); + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1 || this.maxEntries > DEFAULT_MAX_ENTRIES) throw new RangeError('private structure cache entry cap is invalid'); + if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1024 || this.maxBytes > DEFAULT_MAX_BYTES) throw new RangeError('private structure cache byte cap is invalid'); + } + + get size(): number { return this.entries.size; } + get byteSize(): number { return this.bytes; } + stats(): { entries: number; bytes: number; maxEntries: number; maxBytes: number } { return { entries: this.entries.size, bytes: this.bytes, maxEntries: this.maxEntries, maxBytes: this.maxBytes }; } + + get(context: PrivateStructureContext, structureId: DecimalId): PrivateStructureAccessObservation | null { + const key = privateStructureCacheKey(context, structureId); + const entry = this.entries.get(key); + if (!entry) return null; + const now = nowOf(this.now); + if (entry.value.expiresAt <= now) { this.delete(key); return null; } + // Touching an entry makes eviction LRU while retaining deterministic order. + this.entries.delete(key); + entry.touchedAt = now; + this.entries.set(key, entry); + return { ...entry.value }; + } + + set(context: PrivateStructureContext, structureId: DecimalId, value: PrivateStructureAccessObservation): boolean { + const key = privateStructureCacheKey(context, structureId); + const now = nowOf(this.now); + const checked = validateObservation(value, validatePrivateStructureContext(context), structureId, now); + const bytes = new TextEncoder().encode(JSON.stringify({ key, value: checked })).byteLength; + if (bytes > this.maxBytes) return false; + this.delete(key); + this.entries.set(key, { key, value: checked, bytes, touchedAt: now }); + this.bytes += bytes; + this.evict(); + return this.entries.has(key); + } + + async getOrLoad(context: PrivateStructureContext, structureId: DecimalId, reader: PrivateStructureReader): Promise { + const hit = this.get(context, structureId); + if (hit) return hit; + const validContext = validatePrivateStructureContext(context); + const value = await reader(validContext, structureId); + const checked = validateObservation(value, validContext, structureId, nowOf(this.now)); + this.set(validContext, structureId, checked); + return { ...checked }; + } + + /** Remove one authority key. A revision change can use invalidateAuthority. */ + invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { + const valid = validatePrivateStructureContext(context); + if (structureId !== undefined) { + const key = privateStructureCacheKey(valid, structureId); + return this.delete(key) ? 1 : 0; + } + return this.invalidateAuthority(valid); + } + + /** Invalidate every revision/scope entry for this user/principal/character. */ + invalidateAuthority(context: PrivateStructureContext): number { + const valid = validatePrivateStructureContext(context); + let removed = 0; + for (const [key, entry] of this.entries) { + // Keys are stable JSON; parsing is safe because all keys are generated here. + const parsed = JSON.parse(key) as Partial & { structureId?: string }; + if (parsed.userId === valid.userId && parsed.principal === valid.principal && parsed.characterId === valid.characterId) { + this.delete(key); removed += 1; + } + } + return removed; + } + + clear(): void { this.entries.clear(); this.bytes = 0; } + + private delete(key: string): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + this.entries.delete(key); + this.bytes -= entry.bytes; + return true; + } + private evict(): void { + while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) { + const oldest = this.entries.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.delete(oldest); + } + } +} + +/** A port that adds the bounded cache to a typed private ESI reader. */ +/** + * Adapter from the fixed ESI structure operation to the authority-isolated + * cache port. It accepts a token provider rather than retaining tokens in the + * cache or its keys. + */ +export class EsiPrivateStructurePort implements PrivateStructurePort { + constructor( + private readonly esi: EsiClient, + private readonly accessToken: string | ((context: PrivateStructureContext) => Promise | string), + private readonly now: () => number = () => Date.now(), + private readonly ttlMs = 60 * 60 * 1000, + private readonly hooks?: { beforeAccess?: () => Promise; onTransient?: (error: unknown) => Promise }, + ) {} + + async access(context: PrivateStructureContext, structureId: DecimalId): Promise { + const valid = validatePrivateStructureContext(context); + assertDecimalId(structureId, 'structure ID'); + await this.hooks?.beforeAccess?.(); + const observedAt = this.now(); + if (!Number.isFinite(observedAt)) throw new RangeError('private structure adapter clock is invalid'); + if (!valid.scopes.includes(ESI_STRUCTURES_SCOPE)) { + return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'required structure scope is not granted' }; + } + const token = typeof this.accessToken === 'function' ? await this.accessToken(valid) : this.accessToken; + try { + const observation = await this.esi.getStructure(valid.characterId, structureId, token, valid as AuthorizationKey); + return { + structureId, characterId: valid.characterId, status: 'accessible', evidence: 'esi_structure_access', + name: observation.name, observedAt: observation.retrievedAt, expiresAt: observation.expiresAt, + dockingPermission: 'unknown', solarSystemId: observation.solarSystemId, ownerId: observation.ownerId, + typeId: observation.typeId, position: observation.position, + }; + } catch (error) { + if (error instanceof EsiHttpError && error.status === 403) { + return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI denied structure access' }; + } + if (error instanceof EsiHttpError && error.status === 404) { + return { structureId, characterId: valid.characterId, status: 'unavailable', evidence: 'unavailable', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI did not return the structure' }; + } + if (error instanceof EsiRateLimitError || (error instanceof EsiError && (error.code === 'network' || error.code === 'deadline' || (error instanceof EsiHttpError && (error.status === 408 || error.status === 420 || error.status === 425 || error.status === 429 || error.status >= 500))))) await this.hooks?.onTransient?.(error); + // Transport/deadline/rate-limit failures remain errors so the worker can + // preserve retry/cooldown semantics; explicit 403/404 are observations. + if (error instanceof EsiError) throw error; + throw new Error('private structure lookup failed'); + } + } +} + +export class CachedPrivateStructurePort { + constructor(private readonly reader: PrivateStructureReader, private readonly cache = new PrivateStructureCache(), private readonly beforeAccess?: () => Promise) {} + async access(context: PrivateStructureContext, structureId: DecimalId): Promise { + await this.beforeAccess?.(); + return await this.cache.getOrLoad(context, structureId, this.reader); + } + read(context: PrivateStructureContext, structureId: DecimalId): Promise { + return this.access(context, structureId); + } + invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { return this.cache.invalidate(context, structureId); } + getCache(): PrivateStructureCache { return this.cache; } +} + +export const AuthIsolatedPrivateStructureCache = PrivateStructureCache; +export const PrivateStructureAccessCache = PrivateStructureCache; diff --git a/src/lib/server/release-gate.ts b/src/lib/server/release-gate.ts index 91924a6..f4e59ce 100644 --- a/src/lib/server/release-gate.ts +++ b/src/lib/server/release-gate.ts @@ -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]; diff --git a/src/lib/server/store.ts b/src/lib/server/store.ts index 63b22a0..7a4b49f 100644 --- a/src/lib/server/store.ts +++ b/src/lib/server/store.ts @@ -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(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 { - return await this.workerMutation(MUTATION.commitSyncJob, { jobId, leaseToken, chunkCount, syncedAt, expiresAt }); + public async stageSyncEnrichment(jobId: string, leaseToken: string, index: number, rows: EnrichedLocationNode[]): Promise { + await this.workerMutation(MUTATION.stageSyncEnrichment, { jobId, leaseToken, index, rows }); + } + + public async resetSyncEnrichment(jobId: string, leaseToken: string): Promise { + 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 { + return await this.workerMutation(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 { + return await this.query(QUERY.enrichmentManifest, { userId, characterId, ...(expectedGeneration === undefined ? {} : { expectedGeneration }) }); + } + + public async enrichmentChunks(userId: string, characterId: number, generation: string): Promise { + 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(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 { @@ -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 { + return await this.workerMutation(MUTATION.recordSyncCooldown, { context, until, ...(group === undefined ? {} : { group }) }); + } + public async acquireRefreshLease(userId: string, characterId: number, ownerId: string, leaseMs?: number): Promise { return await this.workerMutation(MUTATION.acquireRefreshLease, { userId, characterId, ownerId, ...(leaseMs === undefined ? {} : { leaseMs }) }); } diff --git a/src/routes/auth/eve/+server.ts b/src/routes/auth/eve/+server.ts index 62ea704..1b9a04d 100644 --- a/src/routes/auth/eve/+server.ts +++ b/src/routes/auth/eve/+server.ts @@ -12,6 +12,7 @@ const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dat const CONSENT_CAPABILITIES = new Set([ '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)); } diff --git a/src/routes/characters/+page.server.ts b/src/routes/characters/+page.server.ts index 3f68418..76a4d52 100644 --- a/src/routes/characters/+page.server.ts +++ b/src/routes/characters/+page.server.ts @@ -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, }; }; diff --git a/src/routes/characters/+page.svelte b/src/routes/characters/+page.svelte index dce985e..aca7d26 100644 --- a/src/routes/characters/+page.svelte +++ b/src/routes/characters/+page.svelte @@ -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';

APPLICATION-USER AGGREGATION

Characters

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.

@@ -22,6 +26,7 @@

Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}

{/if} {#if character.assets.error}

{character.assets.error}

{/if} +

Inventory locations: {data.locationEnabled ? locationState(character) : 'unavailable'}{#if data.locationEnabled && locationState(character) === 'missing'} · Structure consent is required for private location evidence.{/if}

{#if data.mode !== 'demo' && data.identityEnabled}
{#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'} @@ -33,6 +38,14 @@ + {#if character.authorizationStatus === 'active' && data.locationEnabled && character.scopes.includes(assetsScope) && !character.scopes.includes(structuresScope)} +
+ + + + +
+ {/if} {#if character.authorizationStatus === 'active' && data.walletEnabled}
diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index d4d83cb..e9b23d2 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -3,6 +3,7 @@ import { CAPABILITY_DEFINITIONS, CAPABILITY_REQUIREMENTS, ESI_ASSETS_SCOPE, + ESI_STRUCTURES_SCOPE, ESI_WALLET_SCOPE, gateCapabilityWithAuthorization, validateCapabilityScopes, @@ -129,6 +130,9 @@ describe('T02 typed contract fixtures', () => { expect(CAPABILITY_REQUIREMENTS.wallet_balance).toEqual(['wallet_balance']); expect(CAPABILITY_DEFINITIONS.wallet_balance.requiredScopes).toEqual([ESI_WALLET_SCOPE]); expect(CAPABILITY_DEFINITIONS.inventory_query.requiredScopes).toEqual([ESI_ASSETS_SCOPE]); + expect(CAPABILITY_DEFINITIONS.inventory_locations.requiredScopes).toEqual([ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE]); + expect(validateCapabilityScopes('inventory_locations', [ESI_ASSETS_SCOPE]).missingScopes).toEqual([ESI_STRUCTURES_SCOPE]); + expect(validateCapabilityScopes('inventory_locations', [ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE]).valid).toBe(true); expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE]).valid).toBe(true); expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE, ESI_WALLET_SCOPE]).duplicateScopes).toEqual([ESI_WALLET_SCOPE]); expect(validateCapabilityScopes('wallet_balance', []).missingScopes).toEqual([ESI_WALLET_SCOPE]); diff --git a/tests/esi.test.ts b/tests/esi.test.ts index a1334a9..b8a5bd9 100644 --- a/tests/esi.test.ts +++ b/tests/esi.test.ts @@ -16,6 +16,7 @@ import { ESI_ASSET_NAME_MAX_RESPONSE_BYTES, ESI_ASSETS_SCOPE, ESI_COMPATIBILITY_DATE, + ESI_STRUCTURES_SCOPE, ESI_READ_OPERATIONS, ESI_WALLET_MAX_RESPONSE_BYTES, ESI_WALLET_SCOPE, @@ -97,6 +98,18 @@ describe("central ESI client", () => { expect(JSON.stringify(queued.calls)).not.toContain("secret"); }); + test("reads private structures through the fixed lossless endpoint and isolates its scope", async () => { + const queued = queueFetch([response('{"structure_id":9007199254740993,"name":"Private Keep","solar_system_id":30000142,"owner_id":99000001,"type_id":35832,"position":{"x":1,"y":2,"z":3}}', { "Cache-Control": "max-age=60" })]); + const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1_000 }); + const result = await client.getStructure(42, "9007199254740993", "secret-token", authority("u", 42, [ESI_STRUCTURES_SCOPE])); + expect(result.structureId).toBe("9007199254740993"); + expect(result.solarSystemId).toBe("30000142"); + expect(queued.calls[0]?.url).toBe("https://esi.evetech.net/universe/structures/9007199254740993"); + expect(new Headers(queued.calls[0]?.init.headers).get("authorization")).toBe("Bearer secret-token"); + await expect(client.getStructure(42, "9007199254740994", "secret-token", authority("u", 42, [ESI_ASSETS_SCOPE]))).rejects.toThrow("missing required scope"); + expect(ESI_READ_OPERATIONS.characterStructure.requiredScope).toBe(ESI_STRUCTURES_SCOPE); + }); + test("retries transient responses with Retry-After but does not retry 401/403", async () => { const delays: number[] = []; const retryable = queueFetch([ @@ -366,13 +379,14 @@ describe("T06 transport bounds, authority, and metadata", () => { expect(bounded.getCacheStats().cacheEntries).toBeLessThanOrEqual(512); expect(bounded.getCacheStats(7 * 60 * 60 * 1000).cacheEntries).toBe(0); - const weightedBody = `[{\"item_id\":1,\"name\":\"${"x".repeat(900_000)}\"}]`; - const weightedQueue = queueFetch(Array.from({ length: 40 }, () => response(weightedBody, { "Cache-Control": "max-age=60" }))); + const weightedBody = JSON.stringify(Array.from({ length: 1_000 }, (_, index) => ({ item_id: index + 1, name: "x".repeat(512) }))); + const weightedIds = Array.from({ length: 1_000 }, (_, index) => String(index + 1)); + const weightedQueue = queueFetch(Array.from({ length: 80 }, () => response(weightedBody, { "Cache-Control": "max-age=60" }))); const weighted = new EsiClient({ userAgent: "test", fetch: weightedQueue.fetch, now: () => 0 }); - for (let index = 0; index < 40; index += 1) await weighted.getAssetNames(7, ["1"], "token", authority(`weighted-${index}`, 7)); + for (let index = 0; index < 80; index += 1) await weighted.getAssetNames(7, weightedIds, "token", authority(`weighted-${index}`, 7)); const weightedStats = weighted.getCacheStats(); expect(weightedStats.cacheBytes).toBeLessThanOrEqual(ESI_CACHE_MAX_BYTES); - expect(weightedStats.cacheEntries).toBeLessThan(40); + expect(weightedStats.cacheEntries).toBeLessThan(80); const snapshotRows = Array.from({ length: 1_000 }, (_, index) => asset(String(index + 1))); const snapshotBody = JSON.stringify(snapshotRows); diff --git a/tests/fixtures/esi/transport-metadata.json b/tests/fixtures/esi/transport-metadata.json index 0dd7076..eae9dae 100644 --- a/tests/fixtures/esi/transport-metadata.json +++ b/tests/fixtures/esi/transport-metadata.json @@ -2,6 +2,17 @@ "compatibilityDate": "2026-08-18", "evidence": { "assetHelpers": { "source": "docs/research/eve-openapi-evidence.json", "verification": "pinned-authoritative" }, + "privateStructure": { + "source": "docs/research/eve-openapi-evidence.json", + "verification": "pinned-authoritative", + "operationId": "GetUniverseStructuresStructureId", + "route": "/universe/structures/{structure_id}", + "method": "GET", + "requiredScope": "esi-universe.read_structures.v1", + "response": { "status": 200, "mediaType": "application/json", "shape": "name/solar_system_id/owner_id with optional type_id/position" }, + "deniedStatus": 403, + "cache": { "ttlSeconds": 3600, "policy": "ttl-based", "validators": ["Cache-Control", "ETag", "Last-Modified"] } + }, "walletBalance": { "source": "https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18", "verification": "current-retrieval-with-pinned-compatibility-date-not-historical-capture", @@ -28,6 +39,7 @@ "operations": { "characterAssetNames": { "route": "/characters/{character_id}/assets/names", "method": "POST", "requiredScope": "esi-assets.read_assets.v1" }, "characterAssetLocations": { "route": "/characters/{character_id}/assets/locations", "method": "POST", "requiredScope": "esi-assets.read_assets.v1" }, + "characterStructure": { "route": "/universe/structures/{structure_id}", "method": "GET", "requiredScope": "esi-universe.read_structures.v1" }, "characterWalletBalance": { "route": "/characters/{character_id}/wallet", "method": "GET", "requiredScope": "esi-wallet.read_character_wallet.v1" } } } diff --git a/tests/fixtures/inventory/cycles-and-missing.json b/tests/fixtures/inventory/cycles-and-missing.json new file mode 100644 index 0000000..a810a82 --- /dev/null +++ b/tests/fixtures/inventory/cycles-and-missing.json @@ -0,0 +1,5 @@ +[ + {"itemId":"10","characterId":42,"typeId":100,"quantity":1,"locationId":"11","locationType":"item","locationFlag":"Cargo"}, + {"itemId":"11","characterId":42,"typeId":101,"quantity":1,"locationId":"10","locationType":"item","locationFlag":"AutoFit"}, + {"itemId":"12","characterId":42,"typeId":102,"quantity":1,"locationId":"99","locationType":"item","locationFlag":"SpecializedShipHold"} +] diff --git a/tests/fixtures/inventory/nested-assets.json b/tests/fixtures/inventory/nested-assets.json new file mode 100644 index 0000000..a8bcd38 --- /dev/null +++ b/tests/fixtures/inventory/nested-assets.json @@ -0,0 +1,5 @@ +[ + {"itemId":"9007199254740993","characterId":42,"typeId":100,"quantity":1,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":true}, + {"itemId":"9007199254740995","characterId":42,"typeId":101,"quantity":2,"locationId":"9007199254740993","locationType":"item","locationFlag":"Cargo","isSingleton":false}, + {"itemId":"9007199254740997","characterId":42,"typeId":102,"quantity":1,"locationId":"9007199254740995","locationType":"item","locationFlag":"HiSlot0","isSingleton":true} +] diff --git a/tests/inventory-containment.test.ts b/tests/inventory-containment.test.ts new file mode 100644 index 0000000..5839853 --- /dev/null +++ b/tests/inventory-containment.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, test } from 'bun:test'; +import { resolveContainment, classifyFittedFlag } from '../src/lib/server/inventory/containment'; +import type { Asset } from '../src/lib/domain/model'; + +const nested = JSON.parse(readFileSync('tests/fixtures/inventory/nested-assets.json', 'utf8')) as Asset[]; +const cycles = JSON.parse(readFileSync('tests/fixtures/inventory/cycles-and-missing.json', 'utf8')) as Asset[]; + +describe('T08 containment graph', () => { + test('resolves nested character-scoped decimal IDs deterministically', () => { + const first = resolveContainment({ + assets: [...nested].reverse(), + characterId: 42, + names: new Map([['9007199254740993', 'Same name'], ['9007199254740995', 'Same name']]), + typeNames: new Map([[102, 'Fitted module']]), + }); + const second = resolveContainment({ assets: nested, characterId: 42, names: new Map([['9007199254740993', 'Same name'], ['9007199254740995', 'Same name']]), typeNames: new Map([[102, 'Fitted module']]) }); + expect(first.complete).toBe(true); + expect(first.nodes.map((node) => node.itemId)).toEqual(['9007199254740993', '9007199254740995', '9007199254740997']); + expect(first.nodes[2]).toMatchObject({ depth: 2, root: { kind: 'external', id: '60003760' }, fitted: { classification: 'fitted', rawFlag: 'HiSlot0' } }); + expect(first.nodes[2]?.breadcrumbs.map((entry) => entry.name)).toEqual(['Same name', 'Same name']); + expect(first.nodes).toEqual(second.nodes); + }); + + test('preserves ambiguous fit flags rather than guessing', () => { + expect(classifyFittedFlag('AutoFit').classification).toBe('unknown'); + expect(classifyFittedFlag('SpecializedShipHold').classification).toBe('unknown'); + expect(classifyFittedFlag('Cargo').classification).toBe('unknown'); + expect(classifyFittedFlag('Hangar').classification).toBe('unknown'); + expect(classifyFittedFlag('LoSlot7').classification).toBe('fitted'); + }); + + test('reports cycles, missing parents, duplicate IDs and depth caps', () => { + const result = resolveContainment({ assets: cycles, characterId: 42 }); + expect(result.complete).toBe(false); + expect(result.nodes.find((node) => node.itemId === '10')?.unresolved).toContain('cycle'); + expect(result.nodes.find((node) => node.itemId === '11')?.unresolved).toContain('cycle'); + expect(result.nodes.find((node) => node.itemId === '12')?.unresolved).toContain('missing_parent'); + const duplicate = resolveContainment({ assets: [cycles[0]!, { ...cycles[0]!, typeId: 999 }], characterId: 42 }); + expect(duplicate.nodes.every((node) => node.unresolved.includes('duplicate_item_id'))).toBe(true); + const capped = resolveContainment({ assets: nested, characterId: 42, maxDepth: 1 }); + expect(capped.nodes.find((node) => node.itemId === '9007199254740997')?.unresolved).toContain('depth_cap'); + }); + + test('does not infer an item parent from a non-item numeric location', () => { + const result = resolveContainment({ assets: [{ ...nested[1]!, locationId: nested[0]!.itemId, locationType: 'station' }], characterId: 42 }); + expect(result.nodes[0]).toMatchObject({ parentItemId: null, root: { kind: 'external', id: nested[0]!.itemId } }); + expect(result.complete).toBe(true); + }); + + test('bounds a valid 50k chain without recursive stack growth', () => { + const deep = Array.from({ length: 50_000 }, (_, index) => ({ + itemId: String(index + 1), characterId: 42, typeId: 100, quantity: 1, + locationId: index === 0 ? '60003760' : String(index), locationType: index === 0 ? 'station' : 'item', locationFlag: 'Cargo', + })) as Asset[]; + const result = resolveContainment({ assets: deep, characterId: 42 }); + expect(result.nodes).toHaveLength(50_000); + expect(result.complete).toBe(false); + expect(result.nodes.at(-1)?.unresolved).toContain('depth_cap'); + }); + + test('returns safe unresolved nodes for null and invalid-ID input', () => { + const result = resolveContainment({ assets: [null, { itemId: '0', locationId: 'not-an-id', characterId: 42, typeId: 1, quantity: 1, locationType: 'station', locationFlag: 'Hangar' }] as any, characterId: 42 }); + expect(result.nodes[0]?.unresolved).toEqual(expect.arrayContaining(['invalid_asset', 'invalid_item_id', 'invalid_location_id'])); + expect(result.nodes[1]?.unresolved).toEqual(expect.arrayContaining(['invalid_asset', 'invalid_item_id', 'invalid_location_id'])); + }); +}); diff --git a/tests/inventory-location.test.ts b/tests/inventory-location.test.ts new file mode 100644 index 0000000..acc5059 --- /dev/null +++ b/tests/inventory-location.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test'; +import type { Asset } from '../src/lib/domain/model'; +import { createReferenceEnrichmentPin } from '../src/lib/domain/inventory-location'; +import { InventoryLocationService } from '../src/lib/server/inventory/location-service'; +import { EsiPrivateStructurePort, PrivateStructureCache, privateStructureCacheKey } from '../src/lib/server/inventory/private-structure-cache'; +import { EsiHttpError } from '../src/lib/server/esi'; +import type { ReferenceReaderPort } from '../src/lib/domain/reference'; + +const authority = { userId: 'user-a', principal: 'character:42', characterId: 42, scopes: ['esi-assets.read_assets.v1'], grantRevision: 3, authorizationEpoch: 2, credentialVersion: 4 } as const; +const assets: Asset[] = [ + { itemId: '9007199254740993', characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar', isSingleton: true }, + { itemId: '9007199254740995', characterId: 42, typeId: 101, quantity: 2, locationId: '9007199254740993', locationType: 'item', locationFlag: 'Cargo', isSingleton: false }, + { itemId: '9007199254740997', characterId: 42, typeId: 102, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar', isSingleton: true }, +]; + +function reference(): ReferenceReaderPort { + const reader: any = { + pinnedVersion: 'ref-v1', + async types() { return []; }, async version() { return 'ref-v1'; }, + async searchTypes() { return []; }, async searchTypesByName() { return []; }, async categories() { return []; }, async groups() { return []; }, + async regions(selection: any) { return selection.ids?.includes(10000002) ? [{ id: 10000002, name: 'The Forge' }] : []; }, + async constellations() { return []; }, + async systems(selection: any) { return selection.ids?.includes(30000142) ? [{ id: 30000142, name: 'Jita', constellationId: 20000020, regionId: 10000002, x: 0, y: 0, z: 0, securityStatus: 0.9, securityClass: 'highsec' }] : []; }, + async stations(selection: any) { return selection.ids?.includes(60003760) ? [{ id: 60003760, systemId: 30000142, systemName: 'Jita', regionId: 10000002, name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant', typeId: 1531, ownerId: 1000035, evidence: 'system_listed', sourceVerified: false, x: null, y: null, z: null }] : []; }, + pin() { return this; }, close() {}, + }; + return reader as ReferenceReaderPort; +} + +function namesObservation() { + return { requestedIds: assets.map((asset) => asset.itemId), names: [{ itemId: assets[0]!.itemId, name: 'Same name' }, { itemId: assets[1]!.itemId, name: 'Same name' }], missing: [assets[2]!.itemId], retrievedAt: 100, expiresAt: 1_000, complete: false }; +} +function locationsObservation() { + return { requestedIds: assets.map((asset) => asset.itemId), locations: assets.map((asset, index) => ({ itemId: asset.itemId, position: { x: index, y: index === 0 ? 0 : -index, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true }; +} + +describe('T08 location enrichment seam', () => { + test('pins authority, reference, helper freshness and system-listed evidence', async () => { + let now = 200; + const service = new InventoryLocationService({ + reference: reference(), now: () => now, + structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', observedAt: 100, expiresAt: 900, dockingPermission: 'unknown' }; } }, + }); + const result = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation(), structureRevision: 'structures-4' }); + expect(result.status).toBe('complete'); + expect(result.enrichmentVersion).toMatch(/^containment-v1-/); + expect(result.inputPin).toMatchObject({ assetGeneration: 'gen-7', referenceVersion: 'ref-v1', structureRevision: 'structures-4', authority: { userId: 'user-a', characterId: 42, grantRevision: 3 } }); + expect(result.items[0]).toMatchObject({ customName: 'Same name', location: { kind: 'station', evidence: 'system_listed', sourceVerified: false, system: { name: 'Jita', region: { name: 'The Forge' } } } }); + expect(result.items[2]).toMatchObject({ location: { kind: 'structure', name: 'Private Keep', evidence: 'private_structure_access', dockingPermission: 'unknown', observedAt: 100, expiresAt: 900 } }); + expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 900, referenceVersion: 'ref-v1' }]); + expect(result.items[0]?.position).toEqual({ x: 0, y: 0, z: 0 }); + expect(result.items[1]?.breadcrumbs.map((entry) => entry.name)).toEqual(['Same name']); + now = 1_001; + const stale = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation() }); + expect(stale.complete).toBe(false); + expect(stale.items[0]?.nameStatus).toBe('expired'); + expect(stale.items[0]?.customName).toBeNull(); + expect(stale.items[0]?.position).toBeNull(); + expect(stale.unresolved.some((entry) => entry.reasons.includes('name_expired'))).toBe(true); + }); + + test('normalizes a direct single helper batch into the generation pin', () => { + const pin = createReferenceEnrichmentPin({ assetGeneration: 'single-batch', authority, referenceVersion: 'ref-v1', namesBatches: [namesObservation()], locationsBatches: [locationsObservation()] }); + expect(pin.names.requestedIds).toEqual(assets.map((asset) => asset.itemId).sort()); + expect(pin.locations.resolvedIds).toHaveLength(assets.length); + }); + + test('keeps 50k-row enrichment indexed and bounded across helper batches', async () => { + const largeAssets: Asset[] = Array.from({ length: 50_000 }, (_, index) => ({ itemId: String(index + 1), characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' })); + const nameBatches = Array.from({ length: 50 }, (_, batch) => { const ids = largeAssets.slice(batch * 1_000, (batch + 1) * 1_000).map((asset) => asset.itemId); return { requestedIds: ids, names: [], missing: ids, retrievedAt: 100, expiresAt: 1_000, complete: false }; }); + const locationBatches = nameBatches.map((batch) => ({ requestedIds: batch.requestedIds, locations: [], missing: batch.requestedIds, retrievedAt: batch.retrievedAt, expiresAt: batch.expiresAt, complete: batch.complete })); + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: largeAssets, authority, assetGeneration: 'large-generation', namesBatches: nameBatches, locationsBatches: locationBatches }); + expect(result.items).toHaveLength(50_000); + expect(result.status).toBe('complete'); + expect(result.inputPin.names.requestedIds).toHaveLength(50_000); + expect(result.inputPin.locations.requestedIds).toHaveLength(50_000); + }); + + test('retains every bounded helper batch in one generation-level pin', async () => { + const manyAssets: Asset[] = Array.from({ length: 1_001 }, (_, index) => ({ itemId: String(10_000 + index), characterId: 42, typeId: 100, quantity: 1, locationId: '30000142', locationType: 'solar_system', locationFlag: 'Hangar' })); + const makeNames = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), names: slice.map((asset) => ({ itemId: asset.itemId, name: `Item ${asset.itemId}` })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true }); + const makeLocations = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), locations: slice.map((asset) => ({ itemId: asset.itemId, position: { x: 0, y: 0, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true }); + const namesBatches = [makeNames(manyAssets.slice(0, 1_000)), makeNames(manyAssets.slice(1_000))]; + const locationsBatches = [makeLocations(manyAssets.slice(0, 1_000)), makeLocations(manyAssets.slice(1_000))]; + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: manyAssets, authority, assetGeneration: 'many-batches', namesBatches, locationsBatches }); + expect(result.items).toHaveLength(1_001); + expect(result.items[1_000]?.customName).toBe('Item 11000'); + expect(result.inputPin.names.requestedIds).toHaveLength(1_001); + expect(result.inputPin.locations.resolvedIds).toHaveLength(1_001); + expect(result.complete).toBe(true); + }); + + test('retains resolved IDs when a later helper batch is forbidden or unavailable', async () => { + const first = assets[0]!; + const second = { ...assets[1]!, itemId: '9007199254740999', locationId: '30000142', locationType: 'solar_system' as const }; + const observedNames = { requestedIds: [first.itemId], names: [{ itemId: first.itemId, name: 'Observed' }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true }; + const forbiddenNames = { state: 'forbidden' as const, requestedIds: [second.itemId], names: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false }; + const observedLocations = { requestedIds: [first.itemId], locations: [{ itemId: first.itemId, position: { x: 1, y: 2, z: 3 } }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true }; + const unavailableLocations = { state: 'unavailable' as const, requestedIds: [second.itemId], locations: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false }; + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [first, second], authority, assetGeneration: 'mixed-batches', namesBatches: [observedNames, forbiddenNames], locationsBatches: [observedLocations, unavailableLocations] }); + expect(result.inputPin.names).toMatchObject({ state: 'forbidden', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'forbidden' }] }); + expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'unavailable' }] }); + expect(result.items[0]).toMatchObject({ customName: 'Observed', position: { x: 1, y: 2, z: 3 } }); + expect(result.items[1]).toMatchObject({ nameStatus: 'forbidden', locationStatus: 'unavailable' }); + expect(result.complete).toBe(false); + }); + + test('distinguishes helper forbidden/unavailable and never fabricates names', async () => { + const service = new InventoryLocationService({ reference: reference(), now: () => 100 }); + const forbidden = await service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'g1', namesState: 'forbidden', namesRequestedIds: [assets[0]!.itemId], locationsState: 'unavailable', locationsRequestedIds: [assets[0]!.itemId] }); + expect(forbidden.items[0]).toMatchObject({ customName: null, nameStatus: 'forbidden', locationStatus: 'unavailable', location: { name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant' } }); + expect(forbidden.unresolved[0]?.reasons).toEqual(expect.arrayContaining(['name_forbidden', 'location_unavailable'])); + }); + + test('forbidden structures are explicit and do not become docking ACL claims', async () => { + const service = new InventoryLocationService({ reference: reference(), now: () => 100, structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt: 100, expiresAt: 200, dockingPermission: 'unknown' }; } } }); + const result = await service.enrich({ assets: [assets[2]!], authority, assetGeneration: 'g2' }); + expect(result.complete).toBe(false); + expect(result.items[0]?.location).toMatchObject({ kind: 'structure', evidence: 'unknown', name: null, dockingPermission: 'unknown' }); + expect(result.unresolved[0]?.reasons).toContain('forbidden_structure'); + expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 200, referenceVersion: 'ref-v1' }]); + }); + + test('not-requested observations never expose body data, and pins match rows', async () => { + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ + assets: assets.slice(0, 1), authority, assetGeneration: 'not-requested', + names: { ...namesObservation(), state: 'not_requested' }, locations: { ...locationsObservation(), state: 'not_requested' }, + }); + expect(result.items[0]).toMatchObject({ customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested' }); + expect(result.inputPin.names).toMatchObject({ state: 'not_requested', resolvedIds: [] }); + expect(result.inputPin.locations).toMatchObject({ state: 'not_requested', resolvedIds: [] }); + }); + + test('oversized helper arrays reject before traversing elements', async () => { + let nameReads = 0; + const oversizedNames: any[] = new Array(1_001); + Object.defineProperty(oversizedNames, 0, { enumerable: true, get() { nameReads += 1; return { itemId: assets[0]!.itemId, name: 'should-not-read' }; } }); + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-names', names: { ...namesObservation(), names: oversizedNames } as any })).rejects.toThrow('exceeds'); + expect(nameReads).toBe(0); + + let locationReads = 0; + const oversizedLocations: any[] = new Array(1_001); + Object.defineProperty(oversizedLocations, 0, { enumerable: true, get() { locationReads += 1; return { itemId: assets[0]!.itemId, position: { x: 0, y: 0, z: 0 } }; } }); + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-locations', locations: { ...locationsObservation(), locations: oversizedLocations } as any })).rejects.toThrow('exceeds'); + expect(locationReads).toBe(0); + + const oversizedRequested = new Array(1_001).fill('1'); + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-requested', namesRequestedIds: oversizedRequested as any })).rejects.toThrow('namesRequestedIds exceeds'); + }); + + test('unknown and malformed helper states fail closed', async () => { + const unknownNames = { ...namesObservation(), state: 'unknown' }; + const unknownLocations = { ...locationsObservation(), state: 'unknown' }; + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name-state', names: unknownNames as any })).rejects.toThrow('invalid enrichment observation state'); + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-location-state', locations: unknownLocations as any })).rejects.toThrow('invalid enrichment observation state'); + const malformedNames = { ...namesObservation(), names: [{ itemId: assets[0]!.itemId, name: '' }] }; + const malformedLocations = { ...locationsObservation(), locations: locationsObservation().locations.map((entry, index) => index === 0 ? { ...entry, position: { x: Number.NaN, y: 0, z: 0 } } : entry) }; + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name', names: malformedNames as any })).rejects.toThrow('names observation contains invalid'); + await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-position', locations: malformedLocations as any })).rejects.toThrow('locations observation contains invalid'); + }); + + test('requested IDs without observations are pinned unavailable, not not-requested', async () => { + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'missing-observation', namesRequestedIds: [assets[0]!.itemId], locationsRequestedIds: [assets[0]!.itemId] }); + expect(result.items[0]).toMatchObject({ nameStatus: 'unavailable', locationStatus: 'unavailable', customName: null, position: null }); + expect(result.inputPin.names).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] }); + expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] }); + }); + + test('consolidates containment and helper failures to one unresolved record per item', async () => { + const broken = { itemId: '1', characterId: 42, typeId: 1, quantity: 1, locationId: '99', locationType: 'item', locationFlag: 'Cargo' } as Asset; + const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [broken], authority, assetGeneration: 'broken', namesRequestedIds: ['1'], locationsRequestedIds: ['1'] }); + expect(result.unresolved).toHaveLength(1); + expect(result.unresolved[0]).toMatchObject({ itemId: '1', reasons: expect.arrayContaining(['missing_parent', 'name_unavailable', 'location_unavailable']) }); + }); + + test('closes an acquired reference handle when validation throws', async () => { + let closes = 0; + const owner: any = reference(); + const handle: any = { ...owner, pinnedVersion: 'ref-v1', close() { closes += 1; } }; + owner.pin = () => handle; + const service = new InventoryLocationService({ reference: owner, now: () => 200 }); + await expect(service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'throws', maxDepth: 65 })).rejects.toThrow('containment depth'); + expect(closes).toBe(1); + }); + + test('closes a malformed handle returned by pin()', async () => { + let closes = 0; + const owner: any = reference(); + const handle: any = { ...owner, pinnedVersion: undefined, close() { closes += 1; } }; + owner.pin = () => handle; + await expect(new InventoryLocationService({ reference: owner, now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-pin' })).rejects.toThrow('reference pin did not provide a version'); + expect(closes).toBe(1); + }); +}); + +describe('T08 private structure cache', () => { + test('typed ESI structure adapter preserves access evidence and maps only 403/404', async () => { + const context = { ...authority, scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], referenceVersion: 'ref-v1' }; + const accessible = new EsiPrivateStructurePort({ getStructure: async () => ({ structureId: '8000000000000001', name: 'Keep', solarSystemId: '30000142', ownerId: '99000001', typeId: '35832', position: null, retrievedAt: 100, expiresAt: 500 }) } as any, 'token', () => 100); + await expect(accessible.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'accessible', evidence: 'esi_structure_access', solarSystemId: '30000142', dockingPermission: 'unknown' }); + const denied = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(403, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100); + await expect(denied.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'forbidden', evidence: 'esi_forbidden', name: null, dockingPermission: 'unknown' }); + const unavailable = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(500, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100); + await expect(unavailable.access(context, '8000000000000001')).rejects.toBeInstanceOf(EsiHttpError); + }); + + test('isolates user/character/revision/reference and expires with upstream', async () => { + let now = 0; + const cache = new PrivateStructureCache({ now: () => now, maxEntries: 4, maxBytes: 16_000 }); + const context = { ...authority, referenceVersion: 'ref-v1' }; + const other = { ...context, userId: 'user-b', principal: 'character:42' }; + const value = { structureId: '8000000000000001', characterId: 42, status: 'accessible' as const, evidence: 'esi_structure_access' as const, name: 'Keep', observedAt: 0, expiresAt: 100, dockingPermission: 'unknown' as const }; + expect(cache.set(context, value.structureId, value)).toBe(true); + expect(cache.get(context, value.structureId)?.name).toBe('Keep'); + expect(cache.get(other, value.structureId)).toBeNull(); + expect(cache.get({ ...context, grantRevision: 4 }, value.structureId)).toBeNull(); + expect(cache.get({ ...context, referenceVersion: 'ref-v2' }, value.structureId)).toBeNull(); + expect(privateStructureCacheKey(context, value.structureId)).not.toContain('token'); + now = 100; + expect(cache.get(context, value.structureId)).toBeNull(); + }); + + test('bounds entries and invalidates every authority revision', () => { + let now = 0; + const cache = new PrivateStructureCache({ now: () => now, maxEntries: 2, maxBytes: 16_000 }); + const context = { ...authority, referenceVersion: 'r' }; + const make = (id: string) => ({ structureId: id, characterId: 42, status: 'forbidden' as const, evidence: 'esi_forbidden' as const, name: null, observedAt: 0, expiresAt: 50, dockingPermission: 'unknown' as const }); + cache.set(context, '1', make('1')); cache.set({ ...context, grantRevision: 2 }, '2', make('2')); cache.set({ ...context, authorizationEpoch: 3 }, '3', make('3')); + expect(cache.size).toBeLessThanOrEqual(2); + expect(cache.invalidateAuthority(context)).toBeGreaterThan(0); + expect(cache.size).toBe(0); + }); +}); diff --git a/tests/t07-sync.test.ts b/tests/t07-sync.test.ts index 7a2ea77..d7399ac 100644 --- a/tests/t07-sync.test.ts +++ b/tests/t07-sync.test.ts @@ -3,6 +3,8 @@ import { makeFunctionReference } from 'convex/server'; import { convexTest } from 'convex-test'; import schema from '../src/convex/schema'; import { EsiRateLimitError } from '../src/lib/server/esi'; +import { createReferenceEnrichmentPin, deterministicEnrichmentVersion } from '../src/lib/domain/inventory-location'; +import { InventoryLocationService } from '../src/lib/server/inventory/location-service'; import { processSyncJob } from '../workers/sync'; process.env.CONVEX_BRIDGE_SECRET = 'test-bridge-secret'; @@ -163,6 +165,146 @@ describe('T07 durable asset scheduling', () => { await expect(t.mutation(mutation('bridgeFailSyncJob'), { ...worker, jobId: cancelled.jobId, leaseToken: cancelled.leaseToken })).rejects.toThrow(); }); + test('stages private enrichment with the exact generation and exposes an atomic read pin', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 726, sessionHash: 'enrichment-session' }); + await enqueue(t, account, 726, 'enrichment-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'worker-enrichment' }); + const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + const asset = { itemId: '1', characterId: 726, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }; + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] }); + const row = { + ...asset, parentItemId: null, root: { kind: 'external', id: '60003760', locationType: 'station', locationFlag: 'Hangar' }, depth: 0, breadcrumbs: [], + fitted: { classification: 'unknown', rawFlag: 'Hangar', reason: 'ambiguous_special_bay' }, fittedClassification: 'unknown', isFitted: null, + unresolved: [], customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested', + location: { kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null }, + }; + await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: [row] }); + const inputPin = createReferenceEnrichmentPin({ assetGeneration: generation, referenceVersion: 'ref-v1', authority: { userId: account.userId, principal: 'queue-owner', characterId: 726, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, referenceFacts: [{ kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, systemId: null, regionId: null, referenceVersion: 'ref-v1' }] }); + await t.mutation(mutation('bridgeCommitSyncJob'), { + ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, + enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin), enrichmentInputPin: inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: 0, + }); + const page = await t.query(query('bridgeAssetsPage'), { ...bridge, userId: account.userId, characterId: 726, paginationOpts: { numItems: 10, cursor: null } }); + expect(page).toMatchObject({ generation, enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin) }); + const manifest = await t.query(query('bridgeEnrichmentManifest'), { ...bridge, userId: account.userId, characterId: 726, expectedGeneration: generation }); + expect(manifest).toMatchObject({ generation, status: 'partial', version: deterministicEnrichmentVersion(inputPin), unresolvedCount: 0 }); + const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 726, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } }); + expect(chunks.chunks[0]?.rows[0]).toMatchObject({ itemId: '1', location: { dockingPermission: 'unknown' } }); + await expect(t.query(query('bridgeEnrichmentManifest'), { ...bridge, userId: 'other-user', characterId: 726, expectedGeneration: generation })).resolves.toBeNull(); + }); + + test('enrichment first page reports the active generation after a rollover', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 728, sessionHash: 'enrichment-rollover-session' }); + await enqueue(t, account, 728, 'enrichment-rollover-session'); + const first = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'enrichment-rollover-worker-1' }); + const generationOne = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: first.jobId, leaseToken: first.leaseToken }); + await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: first.jobId, leaseToken: first.leaseToken, chunkCount: 0, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 }); + await enqueue(t, account, 728, 'enrichment-rollover-session'); + const second = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'enrichment-rollover-worker-2' }); + const generationTwo = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: second.jobId, leaseToken: second.leaseToken }); + await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: second.jobId, leaseToken: second.leaseToken, chunkCount: 0, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 }); + expect(generationTwo).not.toBe(generationOne); + const page = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 728, expectedGeneration: generationOne, paginationOpts: { numItems: 10, cursor: null } }); + expect(page).toMatchObject({ chunks: [], generation: generationTwo, isDone: true }); + }); + + test('commit rejects forged public metadata even when the root ID is correct', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 727, sessionHash: 'forged-reference-facts-session' }); + await enqueue(t, account, 727, 'forged-reference-facts-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'forged-reference-facts-worker' }); + const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + const asset = { itemId: '2', characterId: 727, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }; + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] }); + const row = { ...asset, parentItemId: null, root: { kind: 'external', id: '60003760', locationType: 'station', locationFlag: 'Hangar' }, depth: 0, breadcrumbs: [], fitted: { classification: 'unknown', rawFlag: 'Hangar', reason: 'ambiguous_special_bay' }, fittedClassification: 'unknown', isFitted: null, unresolved: [], customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested', location: { kind: 'station', id: '60003760', name: 'Forged', parentId: null, evidence: 'system_listed', sourceVerified: false, referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null } }; + await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: [row] }); + const inputPin = createReferenceEnrichmentPin({ assetGeneration: generation, referenceVersion: 'ref-v1', authority: { userId: account.userId, principal: 'queue-owner', characterId: 727, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, referenceFacts: [{ kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, systemId: null, regionId: null, referenceVersion: 'ref-v1' }] }); + await expect(t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin), enrichmentInputPin: inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: 0 })).rejects.toThrow('Public location facts'); + }); + + test('private structure rows must match pinned source facts at commit', async () => { + const t = convexTest({ schema, modules }); + const priorAllowlist = process.env.APP_CAPABILITY_ALLOWLIST; + process.env.APP_CAPABILITY_ALLOWLIST = `${priorAllowlist ?? ''},inventory_locations`; + const account = await login(t, { characterId: 731, sessionHash: 'private-facts-session', capability: 'inventory_locations', scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], requestedScopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'] }); + process.env.APP_CAPABILITY_ALLOWLIST = priorAllowlist; + await enqueue(t, account, 731, 'private-facts-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'private-facts-worker' }); + const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + const asset = { itemId: '8000000000000001', characterId: 731, typeId: 34, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar' }; + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] }); + const service = new InventoryLocationService({ reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any, structures: { async access() { return { structureId: asset.locationId, characterId: 731, status: 'accessible', evidence: 'esi_structure_access', name: 'TRUSTED PRIVATE', observedAt: 100, expiresAt: 1_000, dockingPermission: 'unknown' }; } }, now: () => 200 }); + const authority = { userId: account.userId, principal: 'queue-owner', characterId: 731, scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }; + const enrichment = await service.enrich({ assets: [asset], authority, assetGeneration: generation }); + const forged = structuredClone(enrichment.rows) as any[]; + forged[0] = { ...forged[0], location: { ...forged[0].location, name: 'FORGED PRIVATE' } }; + await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: forged }); + await expect(t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length })).rejects.toThrow('Structure row facts'); + }); + + test('service output with forbidden structure evidence stages and commits as explicit partial enrichment', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 729, sessionHash: 'forbidden-persistence-session' }); + await enqueue(t, account, 729, 'forbidden-persistence-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'forbidden-persistence-worker' }); + const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + const asset = { itemId: '8000000000000001', characterId: 729, typeId: 34, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar' }; + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] }); + const service = new InventoryLocationService({ + reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any, + structures: { async access() { return { structureId: asset.locationId, characterId: 729, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt: 100, expiresAt: 1_000, dockingPermission: 'unknown' }; } }, + now: () => 200, + }); + const enrichment = await service.enrich({ assets: [asset], authority: { userId: account.userId, principal: 'queue-owner', characterId: 729, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, assetGeneration: generation }); + expect(enrichment.rows[0]?.location).toMatchObject({ kind: 'structure', evidence: 'unknown', name: null }); + expect(enrichment.rows[0]?.unresolved).toContain('forbidden_structure'); + await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: enrichment.rows }); + await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: enrichment.status, enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length }); + const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 729, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } }); + expect(chunks.chunks[0]?.rows[0]).toMatchObject({ location: { evidence: 'unknown', name: null }, unresolved: ['forbidden_structure'] }); + }); + + test('indexed persistence bounds a 2.5k raw generation without repeated full scans', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 750, sessionHash: 'large-persistence-session' }); + await enqueue(t, account, 750, 'large-persistence-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'large-persistence-worker' }); + await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + for (let index = 0; index < 10; index += 1) { + const assets = Array.from({ length: 250 }, (_, offset) => ({ itemId: String(index * 250 + offset + 1), characterId: 750, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' })); + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index, assets }); + } + await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 10, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'failed', enrichmentChunkCount: 0, enrichmentUnresolvedCount: 2_500, enrichmentError: 'bounded test fallback' }); + const page = await t.query(query('bridgeAssetsPage'), { ...bridge, userId: account.userId, characterId: 750, paginationOpts: { numItems: 1, cursor: null } }); + expect(page.page).toHaveLength(250); + }); + + test('missing parents, unknown references, and helper misses persist as explicit partial rows', async () => { + const t = convexTest({ schema, modules }); + const account = await login(t, { characterId: 730, sessionHash: 'partial-row-session' }); + await enqueue(t, account, 730, 'partial-row-session'); + const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'partial-row-worker' }); + const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken }); + const rows = [ + { itemId: '100', characterId: 730, typeId: 34, quantity: 1, locationId: '999', locationType: 'item', locationFlag: 'Cargo' }, + { itemId: '101', characterId: 730, typeId: 35, quantity: 1, locationId: '60009999', locationType: 'station', locationFlag: 'Hangar' }, + ]; + await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: rows }); + const observation = { requestedIds: ['100'], names: [], missing: ['100'], retrievedAt: 100, expiresAt: 1_000, complete: false }; + const forbiddenObservation = { state: 'forbidden' as const, requestedIds: ['101'], names: [], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: false }; + const locationObservation = { requestedIds: ['100'], locations: [], missing: ['100'], retrievedAt: 100, expiresAt: 1_000, complete: false }; + const unavailableLocationObservation = { state: 'unavailable' as const, requestedIds: ['101'], locations: [], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: false }; + const service = new InventoryLocationService({ reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any, now: () => 200 }); + const enrichment = await service.enrich({ assets: rows, authority: { userId: account.userId, principal: 'queue-owner', characterId: 730, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, assetGeneration: generation, namesBatches: [observation, forbiddenObservation], locationsBatches: [locationObservation, unavailableLocationObservation] }); + expect(enrichment.rows.map((row) => row.unresolved).flat()).toEqual(expect.arrayContaining(['missing_parent', 'reference_unknown'])); + await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: enrichment.rows }); + await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length }); + const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 730, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } }); + expect(chunks.chunks[0]?.rows.flatMap((row: any) => row.unresolved)).toEqual(expect.arrayContaining(['missing_parent', 'reference_unknown'])); + }); + test('authorization quarantine fences both stale success and stale failure', async () => { const t = convexTest({ schema, modules }); const account = await login(t, { characterId: 727, sessionHash: 'quarantine-session' }); @@ -174,6 +316,38 @@ describe('T07 durable asset scheduling', () => { await expect(t.mutation(mutation('bridgeFailSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken })).rejects.toThrow(); }); + test('worker batches helper calls and publishes enrichment with the staged authority pin', async () => { + const calls: string[] = []; + const claimed = { id: 'job-location', jobId: 'job-location', userId: 'user-location', characterId: 730, leaseToken: 'lease-location', status: 'running', attempts: 1, dueAt: Date.now(), createdAt: Date.now(), updatedAt: Date.now(), grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4, scopes: ['esi-assets.read_assets.v1'] } as any; + const assets = Array.from({ length: 1_001 }, (_, index) => ({ itemId: String(index + 1), characterId: 730, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' })); + const store = { + claimSyncJob: async () => claimed, + renewSyncJob: async () => true, + acquireRefreshLease: async () => ({ leaseToken: 'refresh-location', expiresAt: Date.now() + 120_000, expectedVersion: 1, encryptedTokens: 'sealed', tokenExpiresAt: Date.now() + 120_000, scopes: claimed.scopes, grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4 }), + refreshSyncJobAuthority: async () => ({ grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4, scopes: claimed.scopes }), + beginSyncJob: async () => 'generation-location', cooldown: async () => null, + stageSyncAssets: async () => {}, + stageSyncEnrichment: async (_job: string, _lease: string, index: number) => { calls.push(`enrichment:${index}`); }, + commitSyncJob: async (_job: string, _lease: string, _chunks: number, _synced: number, _expires: number, enrichment: any) => { calls.push(`commit:${enrichment.status}:${enrichment.chunkCount}`); return true; }, + failSyncJob: async () => true, deferSyncJob: async () => true, + }; + const esi = { + getAssets: async () => ({ assets, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 }), + getAssetNames: async (_character: number, ids: string[]) => ({ requestedIds: ids, names: [], missing: ids, retrievedAt: 1, expiresAt: 60_000, complete: false }), + getAssetLocations: async (_character: number, ids: string[]) => ({ requestedIds: ids, locations: [], missing: ids, retrievedAt: 1, expiresAt: 60_000, complete: false }), + }; + let capturedNames = 0; let capturedLocations = 0; + await processSyncJob({ + store: store as any, + cipher: { open: async () => ({ characterId: 730, ownerHash: 'owner', accessToken: 'access', refreshToken: 'refresh', expiresAt: Date.now() + 120_000, scopes: claimed.scopes }) } as any, + sso: {} as any, esi: esi as any, workerId: 'worker-location', pollMs: 1, leaseMs: 600_000, heartbeatMs: 60_000, sleep: async () => {}, + locationServiceFactory: () => ({ enrich: async (request: any) => { capturedNames = request.namesBatches.length; capturedLocations = request.locationsBatches.length; return { status: 'partial', rows: [], unresolved: [{ itemId: null, reasons: ['unavailable_structure'] }], enrichmentVersion: 'containment-v1-worker', inputPin: { assetGeneration: 'generation-location' } }; } } as any), + }); + expect(capturedNames).toBe(2); + expect(capturedLocations).toBe(2); + expect(calls).toEqual(['commit:partial:0']); + }); + test('worker retries applied-then-lost staging and commit acknowledgements idempotently', async () => { const events: string[] = []; let stageCalls = 0; let commitCalls = 0; diff --git a/tests/t07-worker-runtime.test.ts b/tests/t07-worker-runtime.test.ts index a0a8f61..65707b2 100644 --- a/tests/t07-worker-runtime.test.ts +++ b/tests/t07-worker-runtime.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseSyncEnqueueBody } from '../src/lib/client/sync-queue'; import { workerEnvironment } from '../scripts/run-sync-worker'; +import { prepareEnrichmentChunks } from '../workers/sync'; describe('T07 worker runtime boundary', () => { test('bundled worker starts with inert restricted configuration and has no unresolved aliases', async () => { @@ -24,6 +25,16 @@ describe('T07 worker runtime boundary', () => { } finally { await rm(directory, { recursive: true, force: true }); } }); + test('worker preflights oversized enrichment envelopes before any stage mutation', () => { + const rows = Array.from({ length: 100 }, (_, index) => ({ itemId: String(index + 1), payload: 'x'.repeat(6_000) })) as any; + const chunks = prepareEnrichmentChunks(rows); + expect(chunks).not.toBeNull(); + expect(chunks!.length).toBeGreaterThan(1); + expect(Math.max(...chunks!.map((chunk) => new TextEncoder().encode(JSON.stringify(chunk)).byteLength))).toBeLessThanOrEqual(256 * 1024); + expect(prepareEnrichmentChunks([{ payload: 'x'.repeat(300_000) }] as any)).toBeNull(); + expect(prepareEnrichmentChunks(Array.from({ length: 2_500 }, () => ({ payload: 'x'.repeat(1_000) })) as any)).toBeNull(); + }); + test('client preserves structured all-skipped and partial enqueue outcomes on HTTP errors', async () => { expect(parseSyncEnqueueBody({ queued: 0, jobs: [], skipped: [{ characterId: 1, reason: 'queue_full' }], partial: true })).toMatchObject({ queued: 0, skipped: [{ characterId: 1, reason: 'queue_full' }] }); expect(parseSyncEnqueueBody({ queued: 1, jobs: [{ id: 'job', characterId: 2, status: 'queued', attempts: 0, dueAt: 1, createdAt: 1, updatedAt: 1, leaseExpiresAt: null, cancelRequested: false }], skipped: [{ characterId: 3, reason: 'enqueue_failed' }], partial: true })).toMatchObject({ queued: 1, jobs: [{ id: 'job', characterId: 2 }], skipped: [{ characterId: 3, reason: 'enqueue_failed' }] }); @@ -56,8 +67,8 @@ describe('T07 worker runtime boundary', () => { }); test('worker child environment drops Infisical bootstrap credentials and ambient web keys', () => { - const child = workerEnvironment({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', INFISICAL_TOKEN: 'inert-token', INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'inert-client-id', INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'inert-client-secret', CONVEX_BRIDGE_SECRET: 'inert-bridge', APP_AUTH_PRIVATE_KEY: 'inert-jwt', TYPESAFE_API_KEY: 'inert-jev' }); - expect(child).toEqual({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret' }); + const child = workerEnvironment({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', REFERENCE_DIR: '/app/data/reference', INFISICAL_TOKEN: 'inert-token', INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'inert-client-id', INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'inert-client-secret', CONVEX_BRIDGE_SECRET: 'inert-bridge', APP_AUTH_PRIVATE_KEY: 'inert-jwt', TYPESAFE_API_KEY: 'inert-jev' }); + expect(child).toEqual({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', REFERENCE_DIR: '/app/data/reference' }); expect(Object.keys(child).some((key) => key.startsWith('INFISICAL_'))).toBe(false); }); diff --git a/workers/sync.ts b/workers/sync.ts index fc8c874..bf1d53f 100644 --- a/workers/sync.ts +++ b/workers/sync.ts @@ -1,7 +1,11 @@ -import { ConvexStore, type RefreshLease } from '../src/lib/server/store'; +import { ConvexStore, type ClaimedSyncJob, type RefreshLease } from '../src/lib/server/store'; import { TokenCipher } from '../src/lib/server/security'; import { EveSso, type VerifiedGrant } from '../src/lib/server/sso'; -import { EsiClient, EsiError, EsiHttpError, EsiRateLimitError } from '../src/lib/server/esi'; +import { ESI_ASSETS_SCOPE, EsiClient, EsiError, EsiHttpError, EsiRateLimitError } from '../src/lib/server/esi'; +import { InventoryLocationService } from '../src/lib/server/inventory/location-service'; +import { CachedPrivateStructurePort, EsiPrivateStructurePort, PrivateStructureCache } from '../src/lib/server/inventory/private-structure-cache'; +import { SqliteReferenceRepository } from '../src/lib/server/reference/repository'; +import type { EnrichedLocationNode, LocationsObservation, NamesObservation } from '../src/lib/domain/inventory-location'; /** Conservative prototype budgets; these are intentionally configurable and bounded. */ export const SYNC_WORKER_DEFAULTS = { @@ -21,11 +25,71 @@ export type WorkerConfig = { leaseMs: number; heartbeatMs: number; sleep: (ms: number) => Promise; + enrichmentDeadlineMs?: number; + /** Present in the production worker; omitted by legacy/unit configurations. */ + locationServiceFactory?: (accessToken: string, hooks?: { beforeStructure: () => Promise; onTransient: (error: unknown) => Promise }) => InventoryLocationService | null; }; function fixedFailure(): string { return 'Asset synchronization failed'; } const DEFAULT_SYNC_COOLDOWN_GROUP = 'esi:assets'; function cooldownContext(userId: string, characterId: number): string { return `character:${userId}:${characterId}`; } +const HELPER_BATCH_SIZE = 1_000; +const LOCATION_CHUNK_SIZE = 100; +const MAX_ENRICHMENT_PERSIST_BYTES = 2 * 1024 * 1024; +// Keep serialized rows below Convex's 512 KiB validator limit with argument +// envelope headroom; the measured worker ceiling is 192 KiB per chunk. +const MAX_ENRICHMENT_CHUNK_BYTES = 192 * 1024; +const MAX_PERSISTED_ENRICHMENT_ROWS = 2_500; + +function helperObservationFailure(requestedIds: readonly string[], state: 'forbidden' | 'unavailable', now: number): T { + return { + requestedIds: [...requestedIds], names: [], locations: [], missing: [], retrievedAt: now, expiresAt: now + 60 * 60_000, complete: false, state, + } as unknown as T; +} + +function asNamesObservation(value: Awaited>, characterId: number): NamesObservation { + return { ...value, names: value.names.map((entry) => ({ itemId: entry.itemId, name: entry.name })), characterId }; +} +function asLocationsObservation(value: Awaited>, characterId: number): LocationsObservation { + return { ...value, locations: value.locations.map((entry) => ({ itemId: entry.itemId, position: { ...entry.position } })), characterId }; +} + +function isOptionalEnrichmentEnvelopeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /Invalid location enrichment (?:chunk|rows)|Location enrichment exceeds persistence (?:byte|row) bound|Location enrichment chunk exceeds byte bound/.test(message); +} + +export function prepareEnrichmentChunks(rows: readonly EnrichedLocationNode[]): EnrichedLocationNode[][] | null { + if (rows.length > MAX_PERSISTED_ENRICHMENT_ROWS) return null; + const chunks: EnrichedLocationNode[][] = []; + let current: EnrichedLocationNode[] = []; + let totalBytes = 2; + for (const row of rows) { + const singleBytes = new TextEncoder().encode(JSON.stringify([row])).byteLength; + if (singleBytes > MAX_ENRICHMENT_CHUNK_BYTES) return null; + const candidate = [...current, row]; + const candidateBytes = new TextEncoder().encode(JSON.stringify(candidate)).byteLength; + if (current.length > 0 && (candidate.length > LOCATION_CHUNK_SIZE || candidateBytes > MAX_ENRICHMENT_CHUNK_BYTES)) { + const serialized = JSON.stringify(current); + totalBytes += new TextEncoder().encode(serialized).byteLength; + if (totalBytes > MAX_ENRICHMENT_PERSIST_BYTES) return null; + chunks.push(current); + current = [row]; + } else { + current = candidate; + } + } + if (current.length > 0) { + totalBytes += new TextEncoder().encode(JSON.stringify(current)).byteLength; + if (totalBytes > MAX_ENRICHMENT_PERSIST_BYTES) return null; + chunks.push(current); + } + return chunks; +} + +export class EnrichmentBudgetExceeded extends Error { + constructor() { super('Optional location enrichment budget exhausted'); this.name = 'EnrichmentBudgetExceeded'; } +} export class MutationUncertainError extends Error { constructor(cause: unknown) { super('Synchronization mutation acknowledgement was uncertain'); this.name = 'MutationUncertainError'; this.cause = cause; } @@ -33,7 +97,7 @@ export class MutationUncertainError extends Error { function permanentMutationFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return /lease is not valid|authorization (?:is )?stale|authorization changed|generation is not current|already leased|Invalid asset chunk|Asset character mismatch|chunks must be staged|chunk already exists|Snapshot is not staging|Snapshot is incomplete|Invalid snapshot|not active|missing|Invalid refreshed scopes/.test(message); + return /lease is not valid|authorization (?:is )?stale|authorization changed|generation is not current|already leased|Invalid asset chunk|Asset character mismatch|Invalid location enrichment chunk|Invalid location enrichment rows|Location enrichment chunk already exists|Location enrichment chunks must be staged|Location enrichment is incomplete|chunks must be staged|chunk already exists|Snapshot is not staging|Snapshot is incomplete|Invalid snapshot|not active|missing|Invalid refreshed scopes|exceeds byte bound|does not match staged asset facts|unrequested item ID|duplicate item IDs|does not cover the staged asset generation|deterministic|truthful|invalid pin/.test(message); } async function retryMutation(operation: () => Promise, renew?: () => Promise): Promise { @@ -55,6 +119,56 @@ async function retryMutation(operation: () => Promise, renew?: () => Promi throw new MutationUncertainError(lastError); } +async function observeAssetHelpers( + config: WorkerConfig, + claimed: ClaimedSyncJob, + authority: { userId: string; principal: string; characterId: number; scopes: readonly string[]; grantRevision: number; authorizationEpoch: number; credentialVersion: number }, + accessToken: string, + assetIds: readonly string[], + revalidate: () => Promise, + deadlineAt: number, +): Promise<{ names: NamesObservation[]; locations: LocationsObservation[] }> { + const context = cooldownContext(claimed.userId, claimed.characterId); + const readBatches = async (reader: (ids: string[]) => Promise): Promise => { + const batches: T[] = []; + let unavailable = false; + for (let offset = 0; offset < assetIds.length; offset += HELPER_BATCH_SIZE) { + const ids = assetIds.slice(offset, offset + HELPER_BATCH_SIZE); + if (Date.now() >= deadlineAt) unavailable = true; + if (!unavailable) await revalidate(); + if (unavailable) { batches.push(helperObservationFailure(ids, 'unavailable', Date.now())); continue; } + const cooldown = await config.store.cooldown(context, claimed.cooldownGroup); + if (cooldown && cooldown.until > Date.now()) { + const delay = cooldown.until - Date.now(); + if (Date.now() + delay >= deadlineAt) { unavailable = true; batches.push(helperObservationFailure(ids, 'unavailable', Date.now())); continue; } + await config.sleep(delay); + if (Date.now() >= deadlineAt) { unavailable = true; batches.push(helperObservationFailure(ids, 'unavailable', Date.now())); continue; } + } + try { + batches.push(await reader(ids)); + } catch (error) { + if (error instanceof EsiHttpError && (error.status === 401)) throw error; + if (error instanceof EsiHttpError && (error.status === 403 || error.status === 404)) { + batches.push(helperObservationFailure(ids, error.status === 403 ? 'forbidden' : 'unavailable', Date.now())); + } else 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))))) { + if (error instanceof EsiRateLimitError && config.store.recordSyncCooldown) await config.store.recordSyncCooldown(context, error.retryAt, error.cooldown.group ?? claimed.cooldownGroup); + batches.push(helperObservationFailure(ids, 'unavailable', Date.now())); + unavailable = true; + } else if (error instanceof EsiError || error instanceof TypeError) { + // Schema/validation failures are optional-helper failures, not reasons + // to discard the raw asset snapshot or replay a malformed response. + batches.push(helperObservationFailure(ids, 'unavailable', Date.now())); + unavailable = true; + } else throw error; + } + } + return batches; + }; + const names = await readBatches(async (ids) => asNamesObservation(await config.esi.getAssetNames(authority.characterId, ids, accessToken, authority), authority.characterId)); + const locations = await readBatches(async (ids) => asLocationsObservation(await config.esi.getAssetLocations(authority.characterId, ids, accessToken, authority), authority.characterId)); + return { names, locations }; +} + /** Process one at-least-once delivery. Every write is fenced by the durable lease. */ export async function processSyncJob(config: WorkerConfig): Promise { const claimed = await config.store.claimSyncJob(config.workerId, config.leaseMs); @@ -164,15 +278,89 @@ export async function processSyncJob(config: WorkerConfig): Promise { } as const; const snapshot = await config.esi.getAssets(claimed.characterId, grant.accessToken, authority); const chunks = Math.ceil(snapshot.assets.length / 250); + const requestedEnrichmentMs = config.enrichmentDeadlineMs; + const configuredEnrichmentMs = Math.max(10_000, Math.min(10 * 60_000, Number.isFinite(requestedEnrichmentMs) ? requestedEnrichmentMs! : 120_000)); + const leaseDeadline = typeof claimed.leaseExpiresAt === 'number' && claimed.leaseExpiresAt > Date.now() ? claimed.leaseExpiresAt - 5_000 : Number.POSITIVE_INFINITY; + const enrichmentDeadlineAt = Math.min(Date.now() + configuredEnrichmentMs, leaseDeadline); + const revalidate = async () => { + if (lostLease) throw new Error('lease lost'); + if (!(await config.store.renewSyncJob(claimed.jobId, claimed.leaseToken, config.workerId, config.leaseMs))) throw new Error('lease lost'); + await config.store.refreshSyncJobAuthority(claimed.jobId, claimed.leaseToken, authorityVersion); + }; for (let index = 0; index < chunks; index += 1) { if (lostLease) throw new Error('lease lost'); + if (index > 0) await revalidate(); await retryMutation( () => config.store.stageSyncAssets(claimed.jobId, claimed.leaseToken, index, snapshot.assets.slice(index * 250, (index + 1) * 250)), () => config.store.renewSyncJob(claimed.jobId, claimed.leaseToken, config.workerId, config.leaseMs), ); } + + let enrichment: { status: 'complete' | 'partial' | 'failed' | 'unavailable'; version?: string; inputPin?: any; chunkCount: number; unresolvedCount: number; error?: string } | undefined; + if (config.locationServiceFactory) { + const assetIds = [...new Set(snapshot.assets.map((asset) => asset.itemId))].sort((left, right) => { + const a = BigInt(left); const b = BigInt(right); return a < b ? -1 : a > b ? 1 : 0; + }); + const locationService = config.locationServiceFactory(grant.accessToken, { + beforeStructure: async () => { if (Date.now() >= enrichmentDeadlineAt) throw new EnrichmentBudgetExceeded(); await revalidate(); }, + onTransient: async (error) => { if (error instanceof EsiRateLimitError && config.store.recordSyncCooldown) await config.store.recordSyncCooldown(context, error.retryAt, error.cooldown.group ?? claimed.cooldownGroup); }, + }); + if (!locationService) { + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Reference enrichment unavailable' }; + } else { + const observations = await observeAssetHelpers(config, claimed, authority, grant.accessToken, assetIds, revalidate, enrichmentDeadlineAt); + if (lostLease) throw new Error('lease lost'); + let locationResult; + try { + if (Date.now() >= enrichmentDeadlineAt) throw new EnrichmentBudgetExceeded(); + await revalidate(); + locationResult = await locationService.enrich({ + assets: snapshot.assets, + authority, + assetGeneration: generation, + namesBatches: observations.names, + locationsBatches: observations.locations, + structureRevision: `${authority.grantRevision}:${authority.authorizationEpoch}:${authority.credentialVersion}`, + }); + } catch (error) { + if (error instanceof EnrichmentBudgetExceeded) { + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Location enrichment deadline exceeded' }; + } else 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))))) { + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Optional location enrichment unavailable' }; + } else if ((error instanceof EsiError && !(error instanceof EsiHttpError && error.status === 401)) || error instanceof TypeError) { + // Optional endpoint/schema validation failures are represented as a + // failed enrichment; authority, cancellation, and lease fences are + // ordinary Errors and remain fatal below. + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Optional location enrichment validation failed' }; + } else throw error; + } + if (locationResult) { + const preparedChunks = prepareEnrichmentChunks(locationResult.rows); + if (!preparedChunks) { + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Location enrichment persistence bound exceeded' }; + } else { + const locationChunks = preparedChunks.length; + try { + for (let index = 0; index < locationChunks; index += 1) { + if (lostLease) throw new Error('lease lost'); + await retryMutation( + () => config.store.stageSyncEnrichment(claimed.jobId, claimed.leaseToken, index, preparedChunks[index]!), + () => config.store.renewSyncJob(claimed.jobId, claimed.leaseToken, config.workerId, config.leaseMs), + ); + } + enrichment = { status: locationResult.status, version: locationResult.enrichmentVersion, inputPin: locationResult.inputPin, chunkCount: locationChunks, unresolvedCount: locationResult.unresolved.length }; + } catch (error) { + if (!isOptionalEnrichmentEnvelopeError(error)) throw error; + await config.store.resetSyncEnrichment(claimed.jobId, claimed.leaseToken); + enrichment = { status: 'failed', chunkCount: 0, unresolvedCount: snapshot.assets.length, error: 'Location enrichment persistence envelope exceeded' }; + } + } + } + } + } + if (config.locationServiceFactory) await revalidate(); await retryMutation( - () => config.store.commitSyncJob(claimed.jobId, claimed.leaseToken, chunks, snapshot.syncedAt, snapshot.expiresAt), + () => config.store.commitSyncJob(claimed.jobId, claimed.leaseToken, chunks, snapshot.syncedAt, snapshot.expiresAt, enrichment), () => config.store.renewSyncJob(claimed.jobId, claimed.leaseToken, config.workerId, config.leaseMs), ); return true; @@ -211,8 +399,25 @@ export async function runSyncWorker(): Promise { const userAgent = env('ESI_USER_AGENT'); const sso = new EveSso({ clientId: env('EVE_CLIENT_ID'), clientSecret: process.env.EVE_CLIENT_SECRET || undefined, redirectUri: process.env.EVE_CALLBACK_URL ?? 'http://localhost/unused-worker-callback', userAgent }); const esi = new EsiClient({ userAgent }); - const config: WorkerConfig = { store, cipher, sso, esi, workerId, pollMs, leaseMs, heartbeatMs, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }; if (process.env.SYNC_WORKER_STARTUP_CHECK === '1') return; + // The sync worker receives only a read-only reference catalog mount. It has + // no reference compiler/import credentials and never writes to this path. + let reference: SqliteReferenceRepository | null = null; + let referenceUnavailable = false; + const privateStructureCache = new PrivateStructureCache(); + const configuredEnrichmentDeadline = Number(process.env.SYNC_WORKER_ENRICHMENT_DEADLINE_MS ?? 120_000); + const config: WorkerConfig = { + store, cipher, sso, esi, workerId, pollMs, leaseMs, heartbeatMs, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + enrichmentDeadlineMs: Number.isFinite(configuredEnrichmentDeadline) ? configuredEnrichmentDeadline : 120_000, + locationServiceFactory: (accessToken, hooks) => { + if (referenceUnavailable) return null; + try { reference ??= new SqliteReferenceRepository(env('REFERENCE_DIR'), 'production'); } + catch { referenceUnavailable = true; return null; } + const structures = new CachedPrivateStructurePort((context, structureId) => new EsiPrivateStructurePort(esi, accessToken, undefined, undefined, hooks ? { onTransient: hooks.onTransient } : undefined).access(context, structureId), privateStructureCache, hooks?.beforeStructure); + return new InventoryLocationService({ reference, structures }); + }, + }; for (;;) { const handled = await processSyncJob(config); if (!handled) await config.sleep(pollMs);