Compare commits

..
34 Commits
Author SHA1 Message Date
jb 1b95f22a72 docs: record development pause and resume plan
isolated-ci / verify (push) Failing after 28s
isolated-ci / container (push) Skipped
2026-09-20 12:29:14 -04:00
jb 4c0a98acd2 wip: checkpoint reviewed T13 wallet balance core 2026-09-20 12:27:00 -04:00
jb 84f4272709 wip: checkpoint reviewed T09 inventory query core 2026-09-20 12:26:49 -04:00
jb 0c9197f841 feat: add pinned inventory location enrichment 2026-09-20 12:13:51 -04:00
jb fe7d218195 feat: add durable asset synchronization worker 2026-09-19 22:15:23 -04:00
jb 2675ea8114 feat: add typed ESI transport extensions 2026-09-19 15:43:22 -04:00
jb 4d3c7a7521 feat: add dataset consent and primary character model 2026-09-19 12:12:10 -04:00
jb a3dc8637f0 docs: record first-wave integration evidence and open gates 2026-09-19 10:10:30 -04:00
jb 39b680bb37 fix: reject malformed cursor IDs and page discriminants 2026-09-19 10:08:10 -04:00
jb 373eb60d0b Harden contract helper validators 2026-09-19 10:07:35 -04:00
jb 614bbf9290 test: cover enabled anonymous rate limits 2026-09-19 10:03:04 -04:00
jb 440540657a refactor: share domain reference reader contracts 2026-09-19 10:02:28 -04:00
jb 240e16a258 Align shared reference and opaque cursor contracts 2026-09-19 10:01:47 -04:00
jb 348aa420ec Add dataset and identity contract foundations 2026-09-19 10:01:47 -04:00
jb 6af610d9ce fix: isolate anonymous rollout rate limits 2026-09-19 10:01:23 -04:00
jb 01b8b4642b fix: harden residual route and restore checks 2026-09-19 10:01:23 -04:00
jb cb86497724 fix: preserve route limits and harden restore marker 2026-09-19 10:01:23 -04:00
jb 99fe7e1ca2 docs: record unverified runner boundary 2026-09-19 10:01:23 -04:00
jb 5b0076cb20 fix: fail closed backend capabilities and isolate gate smoke 2026-09-19 10:01:23 -04:00
jb 1371995cbe test: add rollout smoke and restore evidence runbook 2026-09-19 10:01:23 -04:00
jb a6f1089120 fix: restrict and smoke test container CI 2026-09-19 10:01:23 -04:00
jb b13df3fa34 fix: exercise strict release checks in CI 2026-09-19 10:01:23 -04:00
jb 197e6a3a70 fix: harden release artifact checks and fixture isolation 2026-09-19 10:01:23 -04:00
jb 99436225f6 fix: enforce rollout gates across backend entry points 2026-09-19 10:01:23 -04:00
jb 92272d4e9a feat: add isolated release gates and CI checks 2026-09-19 10:01:23 -04:00
jb 0ff3e67568 fix(reference): rebuild pre-index artifacts 2026-09-19 09:57:48 -04:00
jb 0c03a59b02 fix(reference): select verified station deterministically 2026-09-19 09:57:48 -04:00
jb 18515efbdf fix(reference): classify station evidence 2026-09-19 09:53:46 -04:00
jb 7601365621 fix(reference): verify station coverage and rollback inputs 2026-09-19 09:53:46 -04:00
jb 24660edb09 fix(reference): preserve station evidence and rollback schema 2026-09-19 09:53:46 -04:00
jb f7b604f40f feat(reference): add indexed readers and station source 2026-09-19 09:53:46 -04:00
jb 9ddd12fbc5 fix: bound money conversion after cents scaling 2026-09-18 23:55:32 -04:00
jb aea41d8cea feat: add exact signed money parsing and formatting 2026-09-18 23:55:32 -04:00
jb 49495af196 docs: plan first implementation batch across parallel worktrees 2026-09-18 23:33:13 -04:00
117 changed files with 14959 additions and 458 deletions
+7
View File
@@ -8,3 +8,10 @@ build
data
coverage
*.log
*.pem
*.key
*.p12
*.pfx
credentials*
secrets*
.npmrc
+9 -1
View File
@@ -1,5 +1,10 @@
# Default is live. Demo is explicit and visibly labeled; never used as an error fallback.
APP_MODE=live
# Immutable image/application identifier in staging/production; local is for development only.
APP_RELEASE_ID=local
# Comma-separated server-side rollout allowlist. New capabilities are off by default.
APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions
RELEASE_ENV=development
APP_ORIGIN=http://localhost:5173
APP_AUTH_ISSUER=http://localhost:5173
REFERENCE_DIR=./data/reference
@@ -12,6 +17,8 @@ CONVEX_SELF_HOSTED_URL=http://localhost:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=
# Generate independent bridge/encryption/signing values using `bun run keys:generate`.
CONVEX_BRIDGE_SECRET=
# Dedicated worker lifecycle credential; never share the bridge secret.
CONVEX_SYNC_WORKER_SECRET=
TOKEN_ENCRYPTION_KEY=
APP_AUTH_PRIVATE_KEY=
APP_AUTH_JWKS=
@@ -38,4 +45,5 @@ CONVEX_SITE_ORIGIN=http://localhost:3211
# Backend digest exercised by the isolated self-hosted smoke test.
# Pin the optional dashboard too before production use.
CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard:latest
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b
# Production release checks require this optional admin image to be digest-pinned.
+77
View File
@@ -0,0 +1,77 @@
name: isolated-ci
on:
push:
pull_request:
jobs:
verify:
# Runner labels/capabilities must be confirmed by the operator before this
# workflow is considered release evidence. No deployment or secret access is
# performed by this job.
runs-on: ubuntu-latest
container: oven/bun:1.3.14
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install frozen dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Unit and integration tests
run: bun test
- name: Typecheck
run: bun run check
- name: Build fixture application
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run build
- name: Release safety checks
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check
- name: Isolated fixture smoke
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture
- name: Disabled capability HTTP smoke
run: APP_MODE=demo APP_RELEASE_ID=fixture bun run test:release-gates
- name: Strict staging release safety check
run: >-
RELEASE_ENV=staging APP_MODE=live
APP_RELEASE_ID=ci-${{ gitea.sha }}
CONVEX_IMAGE=ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
CONVEX_DASHBOARD_IMAGE=ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b
bun run release:check
container:
# HOLD: no verified dedicated/rootless runner or protected-ref policy has
# been confirmed in Gitea. Keep this disabled rather than treating every
# push as trusted; enable only after operator approval and runner evidence.
if: ${{ false }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate Compose expansion
run: docker compose config --quiet
- name: Build immutable web artifact
run: >-
docker build --build-arg APP_RELEASE_ID=fixture-ci
--tag "eve-intelligence:fixture-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:-1}" .
- name: Run isolated container smoke
shell: bash
run: |
set -euo pipefail
run_id="${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
image="eve-intelligence:fixture-${run_id}-${run_attempt}"
volume="eden-ci-reference-${run_id}-${run_attempt}"
container="eden-ci-web-${run_id}-${run_attempt}"
cleanup() { docker rm --force "$container" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null 2>&1 || true; docker image rm "$image" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker volume create "$volume" >/dev/null
docker run --rm --network none --user root -v "$volume:/app/data/reference" "$image" \
bun workers/reference.ts --fixture --directory /app/data/reference
docker run --detach --name "$container" --network none \
-e APP_MODE=demo -e APP_ORIGIN=http://127.0.0.1:3000 -e ORIGIN=http://127.0.0.1:3000 \
-e HOST=127.0.0.1 -e PORT=3000 -e REFERENCE_DIR=/app/data/reference -e APP_RELEASE_ID=fixture-ci \
-v "$volume:/app/data/reference:ro" "$image" >/dev/null
for attempt in $(seq 1 60); do
if docker exec "$container" bun -e "const r = await fetch('http://127.0.0.1:3000'); if (r.status !== 200 || r.headers.get('x-application-release') !== 'fixture-ci') process.exit(1);"; then break; fi
if [ "$attempt" = 60 ]; then echo 'container smoke did not become ready' >&2; exit 1; fi
sleep 0.5
done
docker exec "$container" bun -e "const r = await fetch('http://127.0.0.1:3000/assets'); if (r.status !== 200) process.exit(1);"
+6 -3
View File
@@ -1,21 +1,24 @@
FROM oven/bun:1.3.14 AS build
FROM oven/bun@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS build
WORKDIR /app
COPY package.json bun.lock ./
# Infisical is a host-side operator tool; its binary installer is unnecessary in the image.
RUN bun install --frozen-lockfile --ignore-scripts
COPY . .
RUN bun run build
RUN bun build workers/sync.ts --target bun --outfile build/sync-worker.js
FROM oven/bun:1.3.14 AS runtime
FROM oven/bun@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS runtime
ARG APP_RELEASE_ID=local
# Production reference imports stream XZ through the OS decoder; no archive extraction shell.
RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 APP_RELEASE_ID=$APP_RELEASE_ID
COPY --from=build /app/package.json /app/bun.lock ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/build ./build
COPY --from=build /app/src/lib ./src/lib
COPY --from=build /app/workers ./workers
COPY --from=build /app/scripts/run-sync-worker.ts ./scripts/run-sync-worker.ts
COPY --from=build /app/fixtures ./fixtures
RUN mkdir -p /app/data/reference && chown -R bun:bun /app/data
USER bun
+15 -9
View File
@@ -44,15 +44,16 @@ Use [the live deployment guide](docs/live-setup.md) for `dev.evebot.app`, the re
bunx convex dev --once
```
`APP_AUTH_ISSUER`, `APP_AUTH_JWKS` and `CONVEX_BRIDGE_SECRET` must be set in **Convex's environment**, not merely the Docker process environment. The configure script handles these three. Custom JWT audience is `eve-intelligence`; the public JWKS data URI avoids a boot dependency on the web server.
`APP_AUTH_ISSUER`, `APP_AUTH_JWKS`, `CONVEX_BRIDGE_SECRET` and `CONVEX_SYNC_WORKER_SECRET` must be set in **Convex's environment**, not merely the Docker process environment. The direct configure command reads the local private `.env`, including `CONVEX_SELF_HOSTED_ADMIN_KEY`; never put that admin key in a web or worker environment. Custom JWT audience is `eve-intelligence`; the public JWKS data URI avoids a boot dependency on the web server.
6. Register an EVE application and exact callback at <https://developers.eveonline.com/>. Set `EVE_CLIENT_ID`, `EVE_CALLBACK_URL`, and `EVE_CLIENT_SECRET` for a confidential web client. A public PKCE client leaves the secret blank. Initial requested scope is only `esi-assets.read_assets.v1`.
6. Register an EVE application and exact callback at <https://developers.eveonline.com/>. Set `EVE_CLIENT_ID`, `EVE_CALLBACK_URL`, and `EVE_CLIENT_SECRET` for a confidential web client. A public PKCE client leaves the secret blank. Initial login requests `esi-assets.read_assets.v1`; later dataset upgrades preserve only server-allowlisted existing scopes.
7. Set `ESI_USER_AGENT` with your actual operator contact. Optional `TYPESAFE_API_KEY` enables server-side JEV routing. Set public/internal Convex URLs as described in `.env.example`.
8. Build the fixture reference database and start `bun run dev`. Connect a character, synchronize it, and add more characters with **Connect EVE Character**. Each connects explicitly to the current application user.
9. In a second terminal, create a private `.env.worker` containing only `CONVEX_URL`, `CONVEX_SYNC_WORKER_SECRET`, `TOKEN_ENCRYPTION_KEY`, `EVE_CLIENT_ID`, optional `EVE_CLIENT_SECRET`, `EVE_CALLBACK_URL`, `ESI_USER_AGENT`, and worker tuning values, then start the worker with `bun --env-file=.env.worker run sync:worker`. Do not source the full web `.env`; the worker launcher intentionally rejects bridge/admin/JWT/JEV credentials.
**Important:** the fixture DB contains only a handful of types. Live assets absent from it remain explicitly unpriced/unknown. Do not describe a fixture-backed report as a complete real-character valuation. See [full-import handoff](docs/reference-operations.md) before ingesting real releases.
The first verified EVE character creates an application user; there is no EVE-account-wide lookup. Reconnecting a character to a different application user or with a changed owner hash fails closed. Account merging/recovery and unlink/revocation UX are not yet implemented; this is a deployment limitation, not an implicit ownership transfer policy.
The first verified EVE character creates an application user and is the sole signed-out login character; additional linked characters are data-only until an authenticated user switches the primary. Reconnecting a character to a different application user or with a changed owner hash fails closed. Local data-only-character disconnection is implemented, but remote EVE revocation, unlink/delete, transfer recovery, and account merging/recovery remain deferred.
## Single-host Docker Compose
@@ -60,10 +61,11 @@ Compose uses Convex's default SQLite backend; it introduces no other datastore.
- `backend`: persistent Convex SQLite volume, API bound to loopback by default (the configured Pangolin deployment explicitly overrides this).
- `web`: Bun web service with read-only reference volume.
- `sync-worker`: dedicated durable asset-sync worker with a bundled runtime and worker-only secret.
- `reference-worker`: dedicated one-shot heavy-ingestion process (`tools` profile).
- `dashboard`: optional loopback-only management UI (`admin` profile).
For local container use, set `APP_ORIGIN` and `APP_AUTH_ISSUER` to `http://localhost:3000` and the EVE callback to `http://localhost:3000/auth/eve/callback`. Repeat `convex:configure` after changing issuer.
For local container use, set `APP_ORIGIN` and `APP_AUTH_ISSUER` to `http://localhost:3000` and the EVE callback to `http://localhost:3000/auth/eve/callback`. Repeat `bun run convex:configure` after changing issuer.
```sh
docker compose up -d backend
@@ -71,13 +73,15 @@ docker compose up -d backend
# Explicit production archive import (~109 MiB for the reviewed releases):
docker compose --profile tools run --rm --build reference-worker
docker compose up -d --build web
# Start the explicitly profiled durable asset worker; Compose forwards only its worker environment:
docker compose --profile worker up -d --build sync-worker
# Optional management UI:
docker compose --profile admin up -d dashboard
```
Invoking the `tools` worker explicitly imports **production archives** into `/app/data/reference`, the shared web/worker volume. `--check` fetches metadata only. Fixture imports require an explicit `--fixture` override and a separate demo output directory/volume; never use a fixture worker against the live volume.
For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend defaults to the immutable digest exercised by this repository’s smoke test. The optional dashboard’s `latest` default is a development convenience: pin a tested digest before production. See [security gates](docs/security.md).
For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend and optional dashboard defaults are immutable digest-pinned images in `compose.yaml`; use only reviewed digest overrides for production. See [security gates](docs/security.md).
## Structure
@@ -96,6 +100,7 @@ docs/research/ current official-source evidence and captured ESI con
- [Architecture](docs/architecture.md) and [milestone scope](docs/milestone.md)
- [Remaining-feature roadmap](docs/roadmap.md): inventory and personal finances first, public signup readiness, then markets, industry, capital logistics, and corporation workspaces
- [First-batch parallel worktree tasks](docs/parallel-worktrees.md): task briefs, dependency waves, shared-file ownership and acceptance gates
- [Reference lifecycle and production imports](docs/reference-operations.md)
- [ESI cache, pagination and compatibility operations](docs/esi-operations.md)
- [EVE/SSO and 2026 structure/ACL evidence](docs/research/eve.md)
@@ -104,16 +109,17 @@ docs/research/ current official-source evidence and captured ESI con
- [Convex/Bun platform evidence](docs/research/platform.md)
- [Security invariants](docs/security.md)
- [Executed checks and remaining verification gates](docs/verification.md)
- [Isolated restore/migration rehearsal runbook](docs/restore-rehearsal.md)
## Operational limits
- The configured development deployment has passed live TypeSafe routing and public Convex WebSocket checks; the operator has confirmed real EVE sign-in, asset synchronization, and matching Assets/chat valuation totals for one character. Live multi-character linking and revocation/transfer scenarios remain separate checks.
- Production imports use reviewed source adapters and immutable SQLite artifacts. Station data is not imported; 21 dangling blueprint references in the reviewed release are preserved explicitly rather than fabricated. See reference operations and the verification record for executed import checks and limitations.
- Asset synchronization runs on demand in a single Bun web process. Convex leases/CAS protect staging and token rotation, but durable background character-sync job recovery and scheduled refresh are future work.
- This checkpoint records local/disposable prototype verification only. It makes no claim of deployed/live ESI verification or production readiness; live multi-character linking, revocation and transfer scenarios remain deferred.
- Production imports use reviewed source adapters and immutable SQLite artifacts. CCP NPC station IDs, system/type/owner/position facts are imported when `npcStations.jsonl` is present; station names remain optional and the production profile explicitly marks station coverage incomplete until an isolated measured replay. Twenty-one dangling blueprint references in the reviewed release are preserved explicitly rather than fabricated. See reference operations and the verification record for executed import checks and limitations.
- Asset synchronization is enqueued by the web process and completed by the separate durable `sync-worker`. Convex leases/CAS protect staging and token rotation; start the worker explicitly with the worker-scoped launcher before testing live synchronization.
- ESI page collections are not transactional snapshots. Stability checks reduce risk; cache rollover/mutation may require a later retry.
- Location IDs are shown explicitly as unresolved. Fitted/nested assets are distinct ESI items; no contents multiplier is applied to ship prices.
- Conversations/execution summaries persist for authenticated live users; this first UI does not yet provide a history browser. A pending guest request is kept only in tab `sessionStorage` and rechecked after login/sync.
- Reference-source state is local to the worker/artifact volume for this milestone; future Convex jobs may initiate/check it without extracting archives inside Convex.
- Add snapshot/session/execution retention, unlink/revocation, key rotation and restore-tested backups before multi-user production. Preserve encryption keys with backups: losing them loses access to stored credentials.
- Add snapshot/session/execution retention, remote revocation and full unlink/delete lifecycle, key rotation and restore-tested backups before multi-user production. Preserve encryption keys with backups: losing them loses access to stored credentials.
EVE Online is a trademark of CCP Games. This project is not endorsed by CCP.
+25 -1
View File
@@ -23,7 +23,7 @@ services:
start_period: 10s
dashboard:
profiles: ["admin"]
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard:latest}
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:63e94c62940a09acd36464e02e1b0a7589f7ef48227003de78510f9523bb4e6b}
ports: ["127.0.0.1:6791:6791"]
environment:
NEXT_PUBLIC_DEPLOYMENT_URL: ${PUBLIC_CONVEX_URL:-http://localhost:3210}
@@ -35,6 +35,8 @@ services:
ports: ["${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-3000}:3000"]
environment:
APP_MODE: ${APP_MODE:-live}
APP_RELEASE_ID: ${APP_RELEASE_ID:-local}
APP_CAPABILITY_ALLOWLIST: ${APP_CAPABILITY_ALLOWLIST-asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions}
ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
CONVEX_URL: http://backend:3210
@@ -52,6 +54,28 @@ services:
volumes: ["reference-data:/app/data/reference:ro"]
depends_on:
backend: { condition: service_healthy }
sync-worker:
profiles: ["worker"]
restart: unless-stopped
build: .
command: ["bun", "scripts/run-sync-worker.ts"]
environment:
CONVEX_URL: http://backend:3210
CONVEX_SYNC_WORKER_SECRET: ${CONVEX_SYNC_WORKER_SECRET:-}
TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY:-}
EVE_CLIENT_ID: ${EVE_CLIENT_ID:-}
EVE_CLIENT_SECRET: ${EVE_CLIENT_SECRET:-}
EVE_CALLBACK_URL: ${EVE_CALLBACK_URL:-http://localhost/unused-worker-callback}
ESI_USER_AGENT: ${ESI_USER_AGENT:-EVE-Intelligence/0.1 (configure operator contact)}
SYNC_WORKER_ID: ${SYNC_WORKER_ID:-local-sync-worker}
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:
profiles: ["tools"]
build: .
+3
View File
@@ -1,5 +1,8 @@
# Public deployment configuration only. Credentials come from Infisical.
APP_MODE=live
APP_RELEASE_ID=local
APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions
RELEASE_ENV=development
APP_ORIGIN=https://dev.evebot.app
APP_AUTH_ISSUER=https://dev.evebot.app
EVE_CALLBACK_URL=https://dev.evebot.app/auth/eve/callback
+1 -1
View File
@@ -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 stages bounded 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. Single-host deployment initially runs one web process; token refresh also uses durable compare-and-swap to prevent a stale refresh from overwriting a rotated token.
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
+63
View File
@@ -0,0 +1,63 @@
# T02 contract record: datasets, authority and pinned reports
**Status:** proposed contracts for the first implementation batch (2026-09-19). This record freezes shapes and invariants, not persistence or worker behavior. It does not approve stale-authority windows, recovery guarantees, retention, quotas or public release policy.
## Invariants
- A `ResourcePrincipal` is resolved by the server. A character, corporation or location ID supplied by a browser, assistant or job payload is a selector and never authorization.
- `grantRevision` (what scopes/resources are authorized) is distinct from `credentialVersion` (which encrypted token/credential generation is current). A revision change invalidates affected jobs, cursors, caches and private reports.
- `DatasetCoverage` distinguishes an atomic snapshot `generation` from an append dataset `checkpoint` and coverage window. `fetchedAt` is the local observation time. `upstreamEffectiveAt` is nullable because ESI often supplies no effective timestamp.
- A dataset read is authorized only when its principal is selected and user-owned, its `authorizationStatus` is active, and its `grantRevision` and `authorizationEpoch` exactly match the selected resource. Ready cached data from an earlier authority epoch is stale and cannot satisfy a gate.
- Snapshot data must stage and validate before publishing a generation. Append data is idempotent by source identity and reports finite coverage/gaps rather than pretending to be an unlimited atomic history.
- `PublicationFence` is attached to both success and failure publication. User jobs require a non-empty resource fence; system/reference/public-feed jobs require an empty one. Corporation-owned jobs require every fenced resource to be the exact corporation principal (no foreign-resource mixing). It covers every selected resource with grant, credential and monotonic authorization-epoch fences; a worker cannot publish after its grant, credential, generation or quarantine fence is stale. This is a contract for later implementation, not an implementation in T02.
- `JobAuthority` is explicit and duplicated job-level principal/revision fields are avoided; the fence is the authority source. A user-owned job cannot promote itself to `system`; system/reference/public-feed jobs have no user dataset authority.
- A `ConsentBinding` binds one OAuth attempt to one initiating session, intent, capability and a non-empty list of per-resource requested/approved-preservation scopes. Accepted callbacks require a pending, unexpired binding; grants echo the exact bound request, are cardinality-matched, and are validated for finite counters, required scopes, server allowlists, grant revision and authorization epoch before persistence. Callback failure with an unknown consumed-code outcome returns `recovery_required`; it must not blindly replay a code. Exact orphan cleanup and recovery UX remain an operator decision.
- Transfer, revocation and quarantine are distinct authorization states. The contract carries an unresolved `StaleAuthorityObservation.maximumWindowMs`; no stale grace period is invented here.
- An inventory cursor is opaque to the caller but carries a server-side pin of selected resources, authorization revisions, epochs, snapshot generations, reference version, price version and expiry plus continuation offset/last-item state. Server state marks cursors `active` or `exhausted`; every sort uses canonical `item_id` tie-breaking, nonnegative safe offsets and canonical decimal last IDs; every lookup rejects duplicate principals, changed caller/query/revisions/input versions, malformed state or expired pins. An `after_end` result always has `nextCursor: null`; an empty initial page must summarize zero matches. Possession of a cursor/report ID is not authorization.
- Inventory summaries use `scope: all_matching_rows`, never the visible page. An empty `after_end` page is a valid terminal response and may retain a non-empty selected summary; `nextCursor: null` and `pageState: 'after_end'` make this explicit. An empty initial page means no matching rows. Clients must not substitute page totals.
- Wallet results contain labelled `wallet_balance` components. They do not expose a combined wealth total or imply journal, transaction, order or profit coverage; each component pins credential version and wallet coverage.
- `Asset.isSingleton` is optional at the legacy boundary; query rows must normalize absence to `isSingleton: null`, never infer singleton state from quantity. T02 only freezes this contract; retaining the ESI fact during ingestion remains a downstream T06/T07 gate.
- A cursor token is opaque. Its unsigned pin is stored server-side and includes server-derived caller identity, query fingerprint, selected resources, revisions, epochs and input versions; every use revalidates that pin and current authorization. Possessing or editing a token cannot authorize or retarget a cursor, and no cursor-signing crypto is selected here.
- Capability consent separates required scopes from the explicit server allowlist and approved-existing scopes preserved during an upgrade. Arbitrary or duplicate requested/preserved scopes are rejected; declarative metadata does not itself replace runtime authorization checks.
- Reports carry one dataset pin per selected resource/dataset, including coverage, authorization revision, authorization epoch and fetch/effective times; compatibility maps are indexes, not the authority. Ready-result provenance requires complete snapshot/append pins, while partial results use explicit partial states.
## Frozen request/result surfaces
`InventoryQuery` is the first query surface: resolved resource selection, validated filters (character/type/name/category/group/quantity/location/system/region/blueprint state), stable sort, bounded page size and a pinned cursor. `InventoryQueryResult` has explicit ready, partial, stale, authorization-required and unavailable states.
`src/lib/domain/reference.ts` is the canonical pure reference-reader contract shared with T03. It preserves narrow `ReferenceRepository.types(ids)/version()`, adds T03-compatible bounded offset search and list selections, and exposes independently closable `pin()`/`snapshot()` handles with immutable `pinnedVersion`. Inventory pagination remains a service-level opaque cursor, not a reference-reader adapter. `ReferenceStationRecord.evidence` (`verified` or `system_listed`) is authoritative, with `sourceVerified` retained as its compatibility projection and nullable names preserved. `LocationReader` returns immutable-versioned facts; verified/system-listed station presence uses `sourceVerified: true/false`, while absent IDs are explicit `kind: 'unknown'` with `sourceVerified: null`, `name: null`, and no guessed parent. Private structure names/evidence are not public reference facts.
`WalletBalanceResult` is opt-in and resource-scoped. Each component carries exact `Isk`, local fetch time, nullable upstream effective time, coverage and authorization revision. Partial character coverage is visible.
Public `market_data` and `reference_data` gates accept only active system/public provenance; an active character or corporation dataset cannot satisfy another user’s public-data requirement.
`ReportProvenance` pins report inputs, authorization revisions, reference/price versions, fetch/effective times and assumptions. Complete reports require the exact selected-resource × required-dataset pin set; each pin echoes its selected resource and complete coverage. It is intentionally smaller than a copied inventory and contains no credentials, cookies or provider payloads.
## Approved money policy boundary
The baseline remains unchanged: existing `Isk` values are integer hundredths serialized as decimal text; market-price validation remains non-negative; numeric quantities remain non-negative safe integers and bigint quantities remain supported. The currently verified ESI market-price converter accepts the unsigned JSON-number decimal subset, permits exponents in `[-1000, 1000]`, and rounds non-negative ties half-up. Its legacy regex may accept a terminal newline; the chosen strict boundary below intentionally tightens that edge rather than claiming zero grammar differences. That baseline is retained as a separate policy; negative market estimates remain invalid.
The approved shared signed extension accepts **ASCII decimal text only**, with strict full-input matching against:
```text
^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$
```
This is the chosen JSON-number grammar for the decimal boundary: no trimming, whitespace, leading `+`, leading-zero integer, `.5`, `1.`, separators, `NaN`, `Infinity`, or non-ASCII digits. The raw input is capped at **8192 characters**, checked before regex or parsing; accepted ASCII input has the same character and UTF-8 byte length. Strict matching must explicitly reject a final newline because JavaScript `$` can otherwise accept one.
After syntax validation, count all mantissa digits (excluding sign and decimal point) and reject more than **4096 coefficient digits**. Inspect the exponent lexically and boundedly before converting it or allocating powers/BigInts. The **cents shift after scaling** (`centsShift = exponent + 2 - fractionDigits`) must be within `[-1000, 1000]`; any excess is rejected, never truncated. For example, `1e998` has shift `1000` and is allowed, while `1e999` has shift `1001` and is rejected. `1.` followed by 1002 zeros has shift `-1000` and is allowed under the coefficient/raw-input limits. Normalize to integer hundredths using fixed **half-away-from-zero** rounding and canonicalize negative zero to `0`. There is no selectable rounding-mode API. Examples: `1.005` → `101`, `-1.005` → `-101` cents.
T02 records this policy only; T04 owns parser/formatter implementation. Newly rejected over-cap, over-coefficient, over-exponent/shift, whitespace, non-ASCII and otherwise invalid inputs are intentional compatibility changes at the new signed boundary. Strict final-newline rejection also tightens the legacy ESI regex edge; preserve the established unsigned non-negative validation and half-up rounding semantics, plus existing quantity behavior.
## Explicit decision gates
1. **Callback/recovery:** exact behavior after a provider code may have been consumed is unresolved; use recovery/new-binding states and never blind replay.
2. **Stale authority:** maximum stale window and remote-revocation retry/deletion deadline are unresolved; quarantine/local fail-closed behavior needs the owning auth/worker decision.
3. **Retention:** generation/checkpoint/report/export retention and GC are unresolved; valid pins must be retained, but no duration or quota is selected here.
4. **Public admission/quotas:** no public limits or signup policy are selected by these contracts.
5. **Money implementation compatibility:** the signed policy is approved and fixed; T04 must preserve the exact grammar, bounds, reject-before-allocation ordering, half-away rounding, negative-zero canonicalization, and baseline unsigned ESI validation/half-up behavior.
6. **Reference source coverage:** T03 must verify station source coverage; the reader contract does not claim stations exist.
## Compatibility and migration
T02 adds type-only contracts, declarative capability metadata, synthetic fixtures and tests. It does not add Convex tables, indexes, migrations, OAuth behavior, workers, schema changes or production routes. Existing `Character`, `Asset`, `AssetReport`, capability gates and repository ports remain source-compatible. `AssetReport.provenance` is optional until downstream services can pin it.
@@ -0,0 +1,25 @@
# T05 design record: dataset authorization and incremental consent
**Status:** local prototype implementation; not a production-readiness or public-launch approval.
## Primary decision
An application user has exactly one **primary** linked character. The primary is the only character that can authenticate the site while signed out. Additional linked characters are **data-only** and cannot authenticate signed out. While authenticated, the user can designate any currently active linked character as the primary; the old primary is demoted in the same Convex transaction.
The active authenticated application session is sufficient to link another character. The callback binds to that initiating session and revalidates it again in the persistence transaction. A character ID supplied by a browser is only a server-resolved selector. A verified owner continuity claim is per character and never groups EVE characters into an account.
## Consent and authority
- Capability definitions select required scopes. The application route combines those with only server-allowlisted scopes already granted to the selected linked character when upgrading/reconnecting.
- OAuth state is one-time and records intent (`initial_login`, `link_character`, `upgrade_dataset`, or `reconnect`), capability, requested/preserved scopes, target character and initiating session hash.
- The verified provider grant, not the requested scope list, is persisted as authority. Scalar and array `scp` claims are normalized by SSO verification. Reduced grants are accepted but block/invalidate affected datasets and remove old asset generations from reads.
- Character records carry role, authorization status, grant revision, credential version and authorization epoch. Dataset statuses carry their own status and matching fences. Local cancellation revokes authority, clears active asset generations, removes credentials and quarantines character jobs where present.
- Owner mismatch never transfers data. The old character is quarantined, credentials and active data access are fenced, and future jobs are quarantined. Full transfer-to-new-owner recovery is explicitly deferred.
Session replacement is performed transactionally with login persistence where possible: the initiating session is revoked as the new session is inserted. OAuth code consumption remains one-time, and callback failures never blindly replay a code.
## Prototype data policy and deferrals
This repository is pre-production and has no users beyond the operator's test character. Schema/model changes are development-only: when local data makes a change costly, wipe/reseed it rather than adding earliest-character migration, lazy-normalization or retention-compatibility machinery. The first linked character is created primary directly; no legacy migration workflow is implemented.
The local prototype keeps essential correctness/security boundaries: server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage, fail-closed owner checks, dataset/read fencing and safe fixed callback errors. It intentionally defers T01 production evidence and full lifecycle work: CI/deployed artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, production session/orphan recovery policy, remote revocation outbox, unlink/delete, and complete transfer recovery. T06 typed transport and T07 durable worker transport are not implemented here. Production migration, retention, rollback and public-launch gates remain mandatory after prototype approval and before P.
+84
View File
@@ -0,0 +1,84 @@
# T07 durable asset synchronization
Status: prototype implementation. This record describes the local, disposable
queue and does not claim production readiness.
## Boundaries
`POST /api/sync` authenticates the application session and only enqueues
server-derived asset jobs. It does not refresh OAuth credentials or call ESI.
Partial enqueue results carry per-character safe skip reasons; an all-failed
queue response is not reported as `202`. The assets and chat surfaces poll
`/api/sync/status?job=<id>`, expose per-character queued/terminal state and
cancellation, and rerun the report only after every queued job succeeds.
`POST /api/sync/cancel` fences a job for its owning user. The Convex bridge
checks the session proof, character ownership, active grant and exact dataset
scope again when enqueueing and claiming.
Jobs are user-owned `asset_sync` rows. The server derives the dedupe key
`user:character:assets`; only `queued` and `running` rows deduplicate. A claim
captures grant revision, authorization epoch, credential version and scopes.
Claims have random lease tokens and a ten-minute default lease, renewed every
minute by the worker. Expired claims are recovered by the next claim
transaction. Selection is bounded to 100 due rows and prefers users with no
running work before due/creation order, providing per-user fairness without a
second scheduler.
Prototype budgets are conservative and explicit (worker lease/poll values are environment-configurable): five attempts per job,
250 assets per staged chunk, 4,096 chunks, 50,000 ESI rows, at most eight
active jobs per user, a ten-minute sync lease, and a 24-hour maximum retry
delay. ESI request/page/response limits are
owned by the T06 transport. Queue backpressure is visible as a bounded failed
job, never a silently truncated generation.
## Fencing and delivery
A worker starts a generation only through the job lease, stages through a
job-token mutation, and commits success or failure through a mutation that
rechecks the lease, job fence, character authorization and generation fence.
Success and failure therefore cannot mutate state after cancellation, scope
loss, credential rotation, quarantine or lease expiry. Staging is contiguous;
identical duplicate chunk delivery is idempotent while a changed duplicate is
rejected. A successful commit is idempotent if a response is lost. Failed
snapshots preserve the previous complete generation.
`isSingleton` is validated by the T06 ESI parser and is carried unchanged in
every stored asset row; absent legacy values remain unknown.
## Refresh coordination
Refresh leases are durable per user/character rows, not process maps. The
worker decrypts a grant only inside the dedicated worker, performs exactly one
remote rotating-token refresh, then commits the replacement with the expected
credential version. The committed credential version/revision/epoch are the
only authority values used for ESI and publication. A lost, timed-out, or
otherwise uncertain refresh result is never replayed: the character becomes
`reconnect_required`, credentials are removed, active work is quarantined, and
all private datasets are blocked until explicit reconnect.
The worker receives only its dedicated `CONVEX_SYNC_WORKER_SECRET`,
token-encryption, EVE OAuth and ESI user-agent credentials. Worker mutations do
not accept the general `CONVEX_BRIDGE_SECRET`; login/session/tenant operations
remain on the web-only bridge. It does not receive application JWT signing
keys, JEV keys, reference paths/artifacts, or deployment-admin credentials. The
web process no longer performs request-bound refresh or asset paging. The image
build bundles `workers/sync.ts` to `build/sync-worker.js`, so the runtime never
executes a raw `$lib`-aliased TypeScript entry. Compose starts that artifact
through `scripts/run-sync-worker.ts`; the launcher rejects bridge, admin,
JWT-signing and JEV credentials even when `bun run sync:worker` is invoked
directly. Start it with `bun run secrets:run --worker -- docker compose --profile worker up -d
--build sync-worker`, never with the general web/deployment secret modes. The Compose
service is explicitly profiled so plain `docker compose up` cannot launch a worker
with empty credentials.
## Cooldowns and recovery
Provider cooldowns are persisted in `syncCooldowns`, keyed by character
context and shared provider group. Asset jobs carry the provider group, and
claim selection plus the worker both honor the group's eligible-at timestamp
before ESI work. ESI remains the single transport retry/cooldown
implementation. No public-feed scheduler is introduced by this task.
Retention, migrations, production quotas, remote revocation, reference
extraction and public market orchestration remain deferred as required by the
parallel-worktree plan.
@@ -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.
+37
View File
@@ -0,0 +1,37 @@
# Development pause and resume handoff
This is an intentional development pause. The accepted work is preserved in separate commits; the T09 and T13 commits are reviewed WIP cores, not durable standalone implementations.
## Checkpoints
- **Completed local checkpoints:** T06 `2675ea81147e4733de41809ab44768022e4d189b`, T07 `fe7d218195f8f501569c5a1e72f302733340be43`, and T08 `0c9197f84167059322621459aeac5e26c1a5bed2`. They are complete local checkpoints, but T06–T08 have not been deployed. T08 finished background inventory enrichment with explicit structure-scope consent, pinned public/private facts, truthful partial/failure fallback, and conservative 2,500-row, 192 KiB chunk, 2 MiB aggregate staging limits (with the separate server-side envelope cap documented in the T08 record). Its synthetic checks are not production-capacity evidence.
- **WIP T09 core:** `84f4272709dcd9326214794a1b8f73aed3764e36` (`wip: checkpoint reviewed T09 inventory query core`). Independently accepted projection/query behavior, but indexed Convex projection/cursor persistence and worker/runtime integration are missing.
- **WIP T13 core:** `4c0a98acd250644a8250d6ff55f5a877365b3004` (`wip: checkpoint reviewed T13 wallet balance core`). Independently accepted balance behavior, but durable queue/schema/worker snapshot/failure persistence and UI/routes/assistant registration are missing.
- T10, T11, and T12 are not started.
Focused verification completed before the WIP commits:
```text
bun test tests/inventory-query.test.ts # 17 pass, 0 fail, 67 expect() calls
bun test tests/wallet-balance.test.ts # 18 pass, 0 fail, 75 expect() calls
```
## Resume plan
Resume T09 shared integration first. T13 may proceed in parallel only in disjoint files; schema, functions, store, application, and worker changes must be serialized. Do not present either pure core as a production or durable implementation.
Product decisions to preserve:
- One primary login; alternate characters are data-only. An active session links/promotes an alternate, and the primary cannot be disconnected until switched.
- Prototype-first disposable data: no migrations, retention policy, or production gates yet.
- Use a dedicated sync worker. Deduplicate active user/character/dataset work; on uncertain refresh use `reconnect_required`. The worker uses a read-only reference catalog.
- Inventory all authorized characters, sort names A–Z, page at 50 rows, and keep Unresolved as a separate filter. Never guess locations.
- Wallet opening auto-enqueues stale balances with dedupe, cooldown, and bounded work. Show per-character balances and a wallet-only total only with complete coverage; never combine wallet and asset wealth.
## Operational boundary
The last actual deployment was T05 `4d3c7a7521cbc2d4757dbea8c20cd9b966bdddbc`. Local Convex was wiped for T05 by agreement, and the compatible compiler 3–6 reference Docker volume was rebuilt. New worker secrets/setup and Convex schema require a deliberate later local deployment; do not generate secrets or deploy during this pause. The known nonfatal `bun:sqlite` build warning remains. Obtain prototype approval before production/T14 work.
The user has now explicitly authorized committing all remaining code and pushing this branch to its existing Gitea upstream. Push authorization is recorded here; the successful push outcome will be recorded only after the normal push command completes. No deploy is authorized.
At the time of this checkpoint, the working tree is clean on `main` (33 commits ahead of `origin/main`); the full test/check/build/diff verification and push remain to be performed.
+91 -18
View File
@@ -1,9 +1,12 @@
# ESI operations
The server ESI adapter uses the fixed trusted base `https://esi.evetech.net` and pins
`X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes only the
character asset GET and global market-price GET needed by the application; callers
cannot provide an arbitrary ESI URL.
`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), and character-scoped private structure
resolution (fixed GET). Callers cannot provide an arbitrary ESI URL, host, method,
or POST body.
## Asset synchronization
@@ -12,37 +15,107 @@ reads `X-Pages`, fetches all remaining pages, de-duplicates by `item_id` while
checking for duplicates, and revalidates page one after the other pages. A changed
page count, duplicate ID, malformed page, failed request, or changed first page
fails the operation. The caller must not persist a returned partial result: the
method returns only a complete `{ assets, syncedAt, expiresAt }` snapshot. Asset
IDs are retained as decimal strings, and the response is parsed with
`lossless-json` so an int64 cannot be rounded by JavaScript.
method returns only a complete bounded-read snapshot with
`consistency: "bounded-non-atomic"`. Asset IDs are retained as decimal strings,
and the response is parsed with `lossless-json` so an int64 cannot be rounded by
JavaScript.
This is a stability check, not a transaction. ESI's paginated endpoint does not
promise a true multi-page snapshot while its cache expires between page requests.
A caller should stage pages and replace the persisted Convex snapshot only after
`getAssets` resolves. Convex persistence is intentionally outside this adapter.
Only page one is conditionally revalidated: revalidating every page would multiply
remote calls up to the bounded page cap and still could not make ESI's independently
served pages atomic. The explicit `bounded-non-atomic` coverage state prevents this
adapter from claiming transactional consistency. A caller should stage pages and
replace the persisted Convex snapshot only after `getAssets` resolves. Convex
persistence is intentionally outside this adapter.
The cache is private to an `EsiClient` and keyed by user/authorization context,
character, and scopes. `authorizationKey` should therefore identify the linked
application user and include the character/scopes when available:
The cache is private to an `EsiClient` and keyed by a structured authorization
context: application user/principal, character, sorted scopes, `grantRevision`,
`authorizationEpoch`, and committed `credentialVersion`. Production callers pass
the current counters:
```ts
{ userId, characterId, scopes }
{ userId, principal, characterId, scopes, grantRevision, authorizationEpoch, credentialVersion }
```
A string key is supported for callers that have no scope object, but is treated as
an isolated user key. Tokens are never used as cache keys or emitted in metrics.
Tokens are never used as cache keys or emitted in metrics. Private reads reject
legacy string keys and require all structured fields, including the matching
character, required scope, `grantRevision`, `authorizationEpoch`, and
`credentialVersion`. Changing any authority counter therefore cannot reuse a prior
private body or snapshot.
Responses retain their raw text, ETag, Last-Modified, and cache metadata. Fresh
responses use the server TTL; stale responses send `If-None-Match` and
`If-Modified-Since`, and a 304 reuses the cached body. `Cache-Control`, `Expires`,
`If-Modified-Since`, and a 304 reuses the cached body for both GET and declared
read-only POST operations. Canonical helper bodies are sorted decimal int64 JSON
literals, so ordering variants share one cache identity; operation/method/body
cannot collide. Helper requests accept 1..1000 unique positive signed-int64 IDs
and reject unsafe/duplicate/oversize input without Number coercion. Each operation
also enforces a response `Content-Length`/stream byte cap and result-row cap before
retaining a cache entry; asset pages and aggregate snapshots have separate bounds
(1,000 pages, 5,000 rows/page, 50,000 rows/snapshot). `Cache-Control`, `Expires`,
`Date`, and `Age` are honored. `no-store` responses retain neither body nor
validators. Requests have one deadline covering response-body reads and bounded
retries for transient network/5xx/420/429 responses. `Retry-After` (seconds or
HTTP date) and the legacy ESI error-limit reset are respected. Exhausted new
limiter buckets are cooled down per authorization context and group; the legacy
error limiter is cooled down globally. A server delay that exceeds the remaining
request budget returns a typed rate-limit error rather than retrying early. 401
and 403 are never blindly retried.
error limiter is cooled down globally. Query a private cooldown with
`client.getCooldown(authorizationKey, characterId)` or the public limiter with
`client.getPublicCooldown()`. A server delay that exceeds the remaining request
budget returns a typed rate-limit error with `retryAt`, `retryAfterMs`, and
scheduler-readable `cooldown` rather than retrying early. Response cancellation is
abort-first and non-blocking, so cleanup cannot extend a deadline. Process-local
retention is bounded by weighted response bytes and aggregate snapshot rows;
`client.getCacheStats()` exposes current usage and both budgets. Eviction is
oldest-insertion deterministic. 401 and 403 are never blindly retried. OAuth exchange/refresh remain in `EveSso` and never use
these safe-read retries.
## Asset names and locations
`EsiClient.getAssetNames` and `getAssetLocations` call fixed character helper
routes with the asset scope. Their outbound body is canonicalized and serialized
as numeric JSON literals (including IDs above `2^53`). Typed observations include
requested IDs, returned values, explicit `missing` IDs, `complete`, and
retrieval/expiry provenance. A missing helper result is never silently represented
as complete enrichment. Duplicate or unexpected IDs, malformed names, non-finite
coordinates, and malformed arrays fail closed. Deterministic transport fixtures live
under `tests/fixtures/esi/` so int64 names, finite positions, signed balances,
and versioned route/scope metadata are parsed from raw JSON rather than rounded
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
wallet scope. The adapter returns only a transport-level observation with signed
integer cents in `amount` (via T04's half-away-from-zero `decimalIskToCents`
normalization) and `retrievedAt` / `expiresAt`; sub-cent source values are rounded
by that approved policy, not rejected. Persistence, scheduling, and UI/service
orchestration belong to later work. Negative and very large balances remain
lossless, while malformed/non-finite numeric bodies are rejected. The tracked
fixture metadata records the official OpenAPI URL, operation ID, 120-second
TTL, `char-wallet` limiter, response SHA-256
`9a9a0f4af77216300e960cfd2576c9baac085a8170022e40c928c28622d80fc6`, and the
2026-09-19T19:30:12Z retrieval. This is a current retrieval using the pinned
compatibility date, not an immutable historical capture from 2026-08-18. Official
references: [ESI OpenAPI](https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18),
[versioning](https://developers.eveonline.com/docs/services/esi/overview/),
[caching](https://developers.eveonline.com/docs/services/esi/overview/#caching),
and [rate limiting](https://developers.eveonline.com/docs/services/esi/rate-limiting/).
## Market estimates
+60
View File
@@ -0,0 +1,60 @@
# First-wave integration evidence
Scope: T01–T04 from [parallel-worktrees.md](parallel-worktrees.md). This is a private local integration record, not a public release or complete M0/M1 acceptance.
## Integrated result
Four lane orchestrators delegated implementation/research and independent reviews in separate worktrees. Parent integration reviews identified and corrected additional issues before local integration. No work from T05 onward was started.
| Lane | Integrated scope | Acceptance boundary |
|---|---|---|
| T01 | Gitea workflow preparation, server rollout gates, release checks, isolated fixture/gate smoke and restore rehearsal documentation | Partial. Container CI is disabled. Browser/OAuth, actual Gitea execution, disposable Convex restore, instance credentials, migration rollback and post-restore invalidation remain unverified or unimplemented. |
| T02 | Dataset/identity/job/report contracts, resource-scoped authorization helpers, opaque server-stored cursor contracts and shared reference ports | Accepted after parent final hardening review. Contracts and helper tests only; no persistence, workers, consent lifecycle or inventory query service implemented. |
| T03 | Indexed immutable reference readers, station-source adapter/evidence, group IDs, schema/rollback validation and verified-station checker | Local fixture/code integration only. Production-source replay was explicitly deferred by the operator; deployed mounted-artifact verification remains open. Station coverage is explicitly incomplete. |
| T04 | Bounded exact signed decimal ingestion, signed money/quantity formatting and non-negative market-price adapter | Implemented and reviewed; shared UI uses exact formatting. No wallet capability implemented. |
Canonical reference reader/record types live in `src/lib/domain/reference.ts`; server types re-export them. Compiler/source/manifest types remain server-only. No duplicate cursor-based reference adapter was introduced.
## Final combined checks
Verified code commit: `39b680bb3724ecf573fec8fdccc3ce3c345e6e87`.
A separate `batch/combined-verification` worktree used its own dependencies/build output and a sanitized environment (`env -i`, explicit PATH/HOME/TMPDIR, `APP_MODE=demo`, `APP_RELEASE_ID=fixture`). No live `.env.local`, persistent backend or reference volume was copied into it. Frozen-lockfile installation completed in that worktree before the final run.
| Command | Result |
|---|---|
| `bun test` | 136 passed, 0 failed; 728 assertions across 22 files |
| `bun run check` | 0 errors, 0 warnings |
| `bun run build` | Passed; known `bun:sqlite` external-resolution warning remains |
| `bun run release:check` | Passed in fixture/demo mode; not production deployment evidence |
| `bun run test:fixture` | Passed with temporary synthetic reference artifacts |
| `bun run test:release-gates` | Passed disabled anonymous/authenticated route checks and enabled anonymous rate-limit thresholds |
| `git diff --check` | Passed |
Enabled-route smoke covers six anonymous sync `401` responses followed by `429`, and thirty schema-invalid chat `400` responses followed by `429`, without backend/provider trap calls. Disabled-route smoke demonstrates early denial before session/backend access. It is not browser automation or a real OAuth/provider test.
## Integration findings corrected
- Signed decimal resource limits now apply **after** scaling to cents; strict whole-input parsing rejects terminal newlines.
- Convex missing/invalid capability configuration fails closed. Web baseline defaults remain for development compatibility. Backend policy must be changed/verified before web policy; changing only web configuration does not revoke existing subscriptions or grants.
- Docker CI remains disabled because runner registration, isolation, labels, protected refs and action availability could not be verified by authorized read-only inspection.
- Anonymous requests no longer consume the same rate-limit bucket twice; authenticated user-keyed limits remain.
- Restore marker commands default to verification and require explicit local/disposable write acknowledgement. Loopback is not itself proof that an instance is disposable. The unsafe arbitrary-target legacy smoke command is not an executable restore runbook step.
- Reference exact lookups preserve group IDs. Station records retain both `sourceVerified` and `verified`/`system_listed` evidence with nullable names.
- Rollback checks reader schema, profile, counts, paths and source-cache/provenance integrity. The verified-station checker uses an indexed deterministic query rather than assuming the first station is verified.
- Importer identity advanced to `reference-compiler/3-6`; a regression demonstrates rebuilding a prior 3-5 artifact missing the newly required station index. Existing artifacts require compatibility checks/rebuild; do not deploy these readers onto an unverified mounted artifact.
- Contract helpers use per-resource grants, approved scope preservation, canonical principal fields, duplicate rejection, cursor revalidation and explicit terminal-page semantics. Independent parent review accepted the final hardening patch without blockers. Typed helpers still require validated runtime envelopes, array/object bounds, coverage shapes and numeric/string fields in future adapters.
## Approved decisions and remaining gates
Approved: deployment capability allowlist; Gitea CI; half-away-from-zero signed rounding; bounded decimal parsing (8192 raw characters, 4096 coefficient digits, exponent and cents shift within ±1000); opaque server-stored inventory cursors; one shared domain reference contract.
Still open:
1. T01 browser/OAuth fixtures and failure injection; actual isolated Convex restore including instance credentials; executable migration rollback and post-restore session/grant invalidation. A runbook is not evidence these mechanisms exist or were exercised.
2. Gitea runner/ref trust validation and an authorized workflow run; container CI remains disabled meanwhile.
3. T03 production-source replay and mounted-reader verification. The operator explicitly deferred the proposed isolated ~109 MiB archive replay; source inspection and synthetic ZIP tests do not prove production coverage.
4. Recovery details, stale-authority windows, retention/GC, quotas and public admission decisions for later tasks. Contracts do not approve these policies.
5. Singleton ingestion/persistence remains a T06/T07 requirement before inventory delivery; optional contracts do not backfill unknown legacy data.
No pushes, public-signup changes or production deployment were performed by this orchestration. Local commits and worktrees are retained. Later tasks must not treat partial T01/T03 acceptance as satisfied hard dependencies without an explicit readiness decision.
+4 -3
View File
@@ -37,7 +37,7 @@ bun run secrets:status
bun run secrets:run -- bun scripts/check-live-config.ts
```
Expected application keys: `EVE_CLIENT_ID`, optional `EVE_CLIENT_SECRET` for confidential SSO, `TYPESAFE_API_KEY`, `TOKEN_ENCRYPTION_KEY`, `CONVEX_BRIDGE_SECRET`, `APP_AUTH_PRIVATE_KEY`, and `APP_AUTH_JWKS`. Generate the last four **once** using `bun run keys:generate`; import `.env.keys` into the selected Infisical environment without replacing existing API keys. The generator refuses to overwrite an existing file. After verifying the import and key pairing, remove the temporary copy. Back up the vault: changing/loss of the encryption key makes stored EVE credentials unreadable. Do not regenerate keys casually during deployment.
Expected application keys: `EVE_CLIENT_ID`, optional `EVE_CLIENT_SECRET` for confidential SSO, `TYPESAFE_API_KEY`, `TOKEN_ENCRYPTION_KEY`, `CONVEX_BRIDGE_SECRET`, `CONVEX_SYNC_WORKER_SECRET`, `APP_AUTH_PRIVATE_KEY`, and `APP_AUTH_JWKS`. Generate the key material **once** using `bun run keys:generate`; import `.env.keys` into the selected Infisical environment without replacing existing API keys. Configure both distinct bridge secrets in Convex with `bun run secrets:run --deployment --configure -- bun run convex:configure`; the sync worker receives only `CONVEX_SYNC_WORKER_SECRET`, while web receives only `CONVEX_BRIDGE_SECRET`. Use `bun run secrets:run --worker -- ...` for worker commands so session/JEV signing and general bridge secrets are not injected. The generator refuses to overwrite an existing file. After verifying the import and key pairing, remove the temporary copy. Back up the vault: changing/loss of the encryption key makes stored EVE credentials unreadable. Do not regenerate keys casually during deployment.
The helper is an operator launcher, not a sandbox. Commands it launches can read injected secrets: never run `env`, `printenv`, shell tracing, or unrestricted `docker compose config` through it. Use `docker compose config --quiet` for validation. Docker administrators can inspect container environments; Infisical startup injection does not conceal secrets from host administrators. Updating a vault value requires recreating the affected container with fresh injected values. Encryption/signing-key rotation needs a separate migration plan.
@@ -47,7 +47,7 @@ Run from the repository root:
```sh
bun run secrets:run -- docker compose up -d backend
bun run secrets:run --deployment -- bun run convex:configure
bun run secrets:run --deployment --configure -- bun run convex:configure
bun run secrets:run --deployment -- bun node_modules/convex/bin/main.js dev --once --typecheck disable
```
@@ -61,9 +61,10 @@ Use the production import procedure in [reference operations](reference-operatio
```sh
bun run secrets:run -- docker compose up -d --build web
bun run secrets:run --worker -- docker compose --profile worker up -d --build sync-worker
```
The container listens on 3000 internally but publishes **5173** on the host. This is the production SvelteKit/Bun server, not the Vite development server. The web mounts reference data read-only. The one-shot reference worker has the writable volume mount.
The web container listens on 3000 internally but publishes **5173** on the host. This is the production SvelteKit/Bun server, not the Vite development server. The web mounts reference data read-only. The dedicated sync-worker is a separate long-running service: its image starts the bundled `build/sync-worker.js` through the restricted launcher and receives only worker-safe secrets. The one-shot reference worker has the writable volume mount.
## First live verification
+250
View File
@@ -0,0 +1,250 @@
# First implementation batch: parallel worktree tasks
**Status:** this document is the approved task plan. T01–T04 have since been worked in parallel; see [first-wave integration evidence](first-wave-verification.md) for implemented scope, checks and remaining gates. This plan itself does not authorize deployment or public signup.
**Prototype order:** the project is pre-production with no users beyond the operator's test character. Prioritize a functional disposable local prototype first: first login, authenticated linking, capability consent/upgrades, reduced-grant invalidation, primary switching, cancellation, sync/read fencing and tenant isolation. Defer CI/deployed-artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, production hardening and similar release evidence until the operator approves that prototype. Essential server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage and fail-closed ownership remain prototype requirements. Before public launch, wipe/reseed local data rather than add costly migration/retention compatibility; this exception ends at production, where migration/retention gates remain mandatory.
**Scope:** the seven work items in [roadmap §15](roadmap.md#15-first-implementation-batch-after-this-plan), split into independently reviewable tasks. The roadmap remains authoritative for security, correctness and definition of done. Later milestones and the remaining M0/public hardening stay in the backlog.
**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/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.
## Execution model
- One task per branch/worktree; branch suggestions below are labels, not created branches.
- A dependency means **merged and verified on the integration base**, not merely started. Rebase dependent worktrees before implementation. Contract-only preparation against mocks can happen earlier but is not integration-ready work.
- Use one integration owner for shared-file patches. Each task supplies its own required schema/store/wiring changes and tests; the integration owner serializes those patches and reruns the combined suite. This is a role, not permission to postpone authorization until integration.
- Prefer feature-local modules and tests over expanding central files. Proposed paths are implementation boundaries, not established APIs. Do not undertake a general architecture rewrite.
- Ready tasks need not all run at once. Assign available agents to independent lanes; more worktrees do not remove dependency or shared-file constraints.
- Every worktree uses synthetic users, isolated Convex state, separate ports/Compose project names, output directories and keys. Do not copy `.env.local`, production volumes, grants or live instance credentials. Never run existing live smoke scripts without reviewing their targets and obtaining consent. Disposable local data may be wiped/reseeded after schema/auth changes; do not build prototype-only migration machinery.
- No branch enables public signup, changes the current ingress gate, performs real-user write smoke tests, or commits secrets. New functionality stays behind the server-side gate from T01 until its acceptance criteria pass.
## Task graph and suggested waves
| ID | Task / suggested branch | Hard dependencies | Size |
|---|---|---|---|
| T01 | Release/test isolation and rollout gate — `batch/release-safety` | None | L |
| T02 | Dataset, identity and feature contracts — `batch/contracts` | None | M |
| T03 | Reference search and station readers — `batch/reference-readers` | None | M–L |
| T04 | Signed decimal ingestion and money formatting — `batch/exact-money` | None | S–M |
| T05 | Dataset authorization and incremental consent — `batch/dataset-consent` | T01, T02 | L |
| T06 | Typed ESI transport extensions — `batch/esi-transport` | T02, T04 | M |
| T07 | Durable asset queue and worker — `batch/asset-worker` | T01, T05, T06 | L |
| T08 | Private locations, custom names and containment — `batch/inventory-locations` | T03, T05, T06 | M–L |
| T09 | Generation-bound inventory projections and query service — `batch/inventory-query` | T03, T07, T08 | L |
| T10 | Inventory browsing UI — `batch/inventory-ui` | T09 | M |
| T11 | Inventory assistant parity — `batch/inventory-assistant` | T09 | M |
| T12 | Saved views and private exports — `batch/inventory-exports` | T09 | M–L |
| T13 | Wallet balance vertical slice — `batch/wallet-balances` | T04, T07 | M–L |
| T14 | Combined regression and private-release evidence — `batch/integration-proof` | T10, T11, T12, T13 | M |
Sizes describe scope, not time estimates. Split an L task further if its contract review reveals independent changes; do not create parallel branches that both redesign the same foundation.
**Suggested schedule (dependency edges, not wave numbers, determine readiness):**
1. **Local prototype wave:** land the minimum T01/T02 contracts and gates needed for T05, then prioritize **T05 + T06** and the existing local asset flow. T03/T04 can continue independently where they unblock functionality.
2. Start **T07 + T08** once their inputs land. Serialize any ESI/application wiring overlap; do not expand into production worker/restore evidence yet.
3. Start **T09 + T13** once their inputs land: this is the inventory/finance split. Wallet balances do not wait for inventory queries.
4. Start **T10 + T11 + T12** once T09 lands; T13 can continue alongside them.
5. **Prototype approval gate:** operator reviews functional local flows and decides whether to invest in operations/release evidence.
6. **Post-approval production wave:** run T01's deferred CI/deployed proof, browser failure injection, backup/restore/migration rollback, retention/GC, quotas/admission and hardening, then run T14 on the approved merged result. Passing prototype tests alone never permits public beta.
## Shared-file ownership and merge discipline
| Collision surface | Change order / owner rule |
|---|---|
| `src/lib/domain/{model,capabilities}.ts` | T02 fixes contracts first; T05 implements dataset gates. T09/T13 add inventory/wallet registrations through serialized patches, not independent contract rewrites. |
| `src/convex/{schema,functions,status}.ts`, `src/lib/server/store.ts` | T05 → T07 foundations. T08, T09, T12 and T13 own only their feature additions; integration owner merges one additive schema/store patch at a time and verifies indexes/migrations. Prefer new feature modules where supported. |
| `src/lib/server/application.ts`, `config.ts`, `compose.yaml` | T01 gate/environment baseline → T05 auth wiring → T07 worker wiring; later feature factories wired serially. T01 owns deployment-tooling changes initially. |
| `src/lib/server/esi/index.ts` | T04 owns only decimal-conversion extraction; T06 follows with transport changes. T07/T08 adapters must not independently rewrite transport; coordinate any remaining central edits. |
| `src/lib/server/reference/*`, `workers/reference.ts` | T03 owns compiler/readers. T08 consumes these contracts, not a competing station importer. |
| `src/lib/components/AssetResult.svelte`, `/assets` | T10 owns browse rendering; T12 adds controls in a follow-on patch after coordinating with T10. T11 consumes the shared result contract without redesigning this component. |
| Assistant intent/service and `/api/chat` | T11 owns inventory adaptation. T13's wallet registration lands serially with T11; finance service work remains independent. |
| `package.json`, `bun.lock`, generated Convex files | Integration owner coordinates dependency changes/code generation. Do not hand-edit generated files or regenerate them independently to mask unresolved schema conflicts. |
| Existing broad test files | Preserve regressions. Prefer task-specific new test files; coordinate modifications to shared fixtures. |
Before merging: review dependency commits, migrations and permissions; run task-specific tests plus `bun test`, `bun run check`, and `bun run build` in the isolated environment. Record results rather than copying the roadmap's historical count. T14 repeats this on the combined tree.
## Worktree briefs
### T01 — Release/test isolation and rollout gate
**Own:** isolated CI/browser/backend fixtures, release/operator scripts, deployment configuration, gate implementation and dedicated tests. Coordinate `package.json`, lockfile and application wiring centrally.
**Deliver:** reproducible test/check/build and deployed-artifact checks; synthetic OAuth/browser fixtures for native forms, callback cookies, linking, logout and pending-request resume; immutable application release identifiers; baseline secret-exclusion review; isolated backup/restore and compatible migration/rollback rehearsal. Back up instance credentials as well as application data and reference artifacts; restored sessions must not reopen traffic with obsolete grants.
**Decision gate:** obtain approval for the server-side rollout mechanism before coding it. No CI-hosting platform, production admission rule or RPO/RTO is selected here. Gates must cover routes, assistant dispatch, jobs and subscriptions, not only UI.
**Done:** baseline regressions remain green; fixtures cannot target the persistent backend accidentally; a restored isolated instance and rollback have recorded evidence; disabled capabilities fail closed across all entry points. No production backup secrets enter logs or artifacts.
### T02 — Dataset, identity and feature contracts
**Own:** typed domain contracts, contract fixtures and a short design record; coordinate changes to `model.ts`/`capabilities.ts`. No production behavior or broad schema migration in this task.
**Deliver:** a reviewed contract for dataset status/coverage, resource principal, required scopes, selected resources, grant/authorization revision, credential version, job/publication fence, and report provenance. Distinguish snapshot generations from append checkpoints and fetched time from unknown upstream effective time.
Define incremental consent, one-time OAuth/session binding, revocation/transfer quarantine, stale-authority behavior, callback failure recovery and refresh uncertainty **before** finalizing the worker. Keep token rotation versions distinct from authorization revisions. Define current user versus server-owned jobs without granting users system authority.
Also freeze the first inventory query/result/filter/cursor contract, reference/location reader interfaces, wallet-balance result, and money normalization policy. Specify `isSingleton` as unknown on legacy rows; selected totals must not mean page totals. A cursor pins authorized resources, revisions, generations, reference and price versions; possession is not authorization.
**Decision gate:** unresolved recovery/stale-authority policies require operator approval; retain roadmap defaults meanwhile. Do not invent a password recovery path, merge accounts by owner hash or decide public quotas here.
**Done:** examples and contract tests cover reduced grants, transfer, stale sessions/workers, partial coverage, large IDs and ambiguous entities. Downstream tasks have explicit requests/results/error states rather than needing to guess. Mark proposed schema changes separately from implemented services.
### T03 — Reference search and station readers
**Own:** `src/lib/server/reference/{schema,types,normalize,compiler,production,repository}.ts`, reference fixtures and dedicated tests; necessary `workers/reference.ts` changes.
**Deliver:** indexed normalized type search, group/category and system/region readers, plus NPC station import from a verified source. Version compiler/schema changes and retain release/source provenance, integrity checks and rollback compatibility. Extend repository contracts agreed in T02 when integrating; initial source investigation and compiler work do not depend on T02.
**Done:** fixture artifact tests exercise search/group/location relationships and station coverage; readers reject incompatible artifacts; unknown locations remain explicit. Verify the actual deployed reader/artifact in an isolated deployment, not only worker stdout. No private structure names enter public SQLite. An unverified station source blocks station completion rather than producing invented coverage.
### T04 — Signed decimal ingestion and money formatting
**Own:** `src/lib/domain/money.ts`, a shared money formatter, focused arithmetic tests; narrow extraction from the current private ESI price converter and existing formatting call sites.
**Deliver:** lossless signed decimal-to-integer-cent conversion with explicit rounding, negative formatting and safe quantity boundaries. Preserve the existing non-negative price validation as a separate policy; do not silently accept negative market estimates. Coordinate policy with T02 before merge.
**Done:** negative fractions, large debits/balances, rounding boundaries, zero and invalid numeric input have golden/property tests. No monetary parsing passes through an imprecise JavaScript number. Existing asset totals/formatting remain correct. This task does not implement a wallet or change valuation methods.
### T05 — Dataset authorization and incremental consent
**Own:** SSO/security/auth routes, dataset capability gates, character management status/actions, initial schema/store/status changes and dedicated auth tests.
**Deliver:** persisted per-dataset status, a single primary login role plus data-only linked roles, grant revisions and credential/authorization epochs; server-allowlisted capability scopes; one-time OAuth binding to requested scopes, operation/link intent and initiating session; verified actual grants; deliberate preservation of required existing scopes. Support upgrade/reconnect, reduced/denied grants, authenticated primary switching, local dataset cancellation/invalidation and visible missing requirements. Keep wallet behind its release capability.
Implement the prototype identity enforcement needed by the pipeline: revalidate linking authority at persistence, local owner/revocation quarantine and job/cache invalidation, and atomic session replacement where practical. Never replay a consumed OAuth code; full transfer-to-new-owner recovery, unlink/delete/session-management UX and production orphan/retention policy remain deferred.
**Done:** scalar/array scopes, stale link sessions, primary-character reauthentication, signed-out data-only rejection, distinct owner bindings, reduced grants, primary switching, cancellation and borrowed selectors pass isolated tests. Transfer cannot expose prior-user data; quarantined credentials cannot service reads or future jobs. This pre-production branch may wipe/reseed local data instead of implementing legacy migrations/lazy normalization; production migration/retention gates remain required before P.
### T06 — Typed ESI transport extensions
**Own:** `src/lib/server/esi/` transport and endpoint-specific adapters/fixtures, plus `docs/esi-operations.md` changes.
**Deliver:** typed endpoint/pagination strategies, explicitly read-only POST support for asset names, lossless outbound int64 serialization and canonical request-body/auth-context cache keys. Add bounded asset-name, location and balance adapter contracts, keeping service orchestration in their respective tasks. Verify routes/scopes against the [planning matrix](research/expansion-data-contracts.md) and pinned fixtures before use.
**Done:** IDs above `2^53`, canonical body variations, grant-revision changes, cache isolation, retry/cooldown/TTL handling, schema failures and page caps have tests. OAuth exchange/refresh does not inherit safe-read retries. No arbitrary URL/method executor, unlimited paging or partial-as-complete result is introduced. Provide the scheduling/cooldown hooks T07 needs without a second independent scheduler.
### T07 — Durable asset queue and worker
**Status:** implemented local prototype; no deployment, live ESI, or production-readiness claim.
**Own:** `workers/sync.ts`, feature-local job/credential modules, job schema/store operations, `/api/sync` and replacement of request-bound sync orchestration; Compose wiring and restricted operator startup.
**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. 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. 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
**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 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.
Compute selected summaries across **all matching rows**, not the visible page, with measured bounded operations. Expose the same deterministic result to UI, chat and exports. Define cursor expiry and reference-retention hooks so future GC cannot remove live inputs.
**Done:** two-user/cross-character and borrowed-cursor tests; stable page boundaries during new publications; singleton/legacy unknown fields; large-account index/read/write budgets; matching summary/page aggregation; missing and stale inputs; auth loss invalidates subsequent requests. Queries that exceed caps fail visibly. No full-inventory scans disguised as bounded paging.
### T10 — Inventory browsing UI
**Own:** `src/routes/assets/`, `src/lib/components/AssetResult.svelte` and inventory-specific components/browser tests.
**Deliver:** search, character/location selectors, grouping/sorting/paging, container breadcrumbs/fitted contents, complete selected totals and explicit missing/stale/unknown states against T09. Do not compute a separate client-side valuation engine.
**Decision gate:** confirm interaction/layout preferences before implementing a significant UX redesign; preserve existing native form behavior and manual flows.
**Done:** isolated native-browser and keyboard tests cover filter changes, paging, ambiguous names, containment, refresh and revoked access; UI report totals match the service. Operator walkthrough evidence uses approved fixture or consenting data. Coordinate export/view controls with T12 rather than both rewriting the page.
### T11 — Inventory assistant parity
**Own:** `src/lib/server/assistant/{intent,service}.ts`, inventory capability registration, manual commands and dedicated intent/parity tests.
**Deliver:** validated item/location/character filters, deterministic resolution within the caller's allowed set, clarification for ambiguous entities, and T09 report consumption. Preserve `/assets` and `/value` fallback behavior; unsupported filters cannot become unfiltered all-assets answers.
**Done:** pinned UI/chat equality, ambiguous and forbidden selectors, malformed model output, prompt injection and unsupported requests have tests. External classification receives no inventories, grants or private location-cache dumps. Persist bounded versioned result summaries/input references rather than introducing new full-report history payloads; expired detail is explicitly unavailable. Coordinate wallet capability registration with T13.
### T12 — Saved views and private exports
**Own:** validated saved-view preferences, private export/report services/routes and tests; feature components with a coordinated T10 wiring patch.
**Deliver:** schema-validated filters, private report references and CSV over T09's exact scope/pinned inputs. Enforce tenant/resource/revision checks on creation, resume and every download. Keep generated files outside public static storage; support expiration, cancellation, retention and deletion hooks.
**Decision gate:** approve concrete export/view quotas and retention before enabling them; no unlimited defaults or public share links.
**Done:** formula-injection escaping, borrowed view/report/export IDs, revoked grants, expired cursors, bounded size/time and cleanup are tested. Summary totals match the entire exported selection. Partial/failed exports never appear complete. Already downloaded files are explicitly not recallable. Coordinate schema changes with T13 and preserve referenced inputs until expiry.
### 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.
**Done:** new/reduced scopes, two-user isolation, lossless negative/large balances, duplicate jobs, stale publication, refresh failure and partial multi-character coverage pass tests. UI/chat use identical pinned results. Unsupported journal, transaction, own-order and historical-profit requests remain unsupported. No combined asset-plus-wallet wealth calculation or invented balance history.
### T14 — Combined regression and private-release evidence
**Own:** cross-feature browser/adversarial scenarios and updates to verification/operator documentation; implementation defects return to their owning task.
**Deliver:** integration evidence for login/upgrade → enqueue/restart → inventory filter/container → saved view/export → matching chat and separate wallet balance. Run test/check/build against the merged tree, isolated deployment/artifact checks and migration/rollback rehearsal with the expanded schemas.
**Done:** two users cannot cross-read through IDs, subscriptions, caches, jobs, cursors or downloads; invalidation races fail closed; query/export/queue resource budgets are recorded; backup/restore preserves required instance credentials and invalidates stale sessions/grants. Distinguish mock, fixture and consenting live evidence. List unverified provider behavior and remaining release blockers. No public-readiness claim follows from passing this batch.
## Roadmap coverage and explicit deferrals
| Roadmap §15 item | Task coverage |
|---|---|
| 1. Baseline/release safety | T01, combined rehearsal in T14 |
| 2. Dataset, scope and identity contracts | T02, T05; consumers tested in T07/T14 |
| 3. Durable asset job slice | T06, T07 |
| 4. Reference/location slice | T03, T06, T08 |
| 5. Inventory query slice | T09; interface parity in T10/T11 |
| 6. Inventory UX slice | T10, T11, T12 |
| 7. First finance slice | T04, T13 |
**Deferred, not waived:** complete M0 identity lifecycle (unlink/delete, session-management/recovery UX and transfer reconciliation), global retention/GC and interaction-history migration, encryption/signing/bridge-key rotation, ongoing reference release scheduling, remaining public quotas/privacy/security/ingress controls, and the entire P release checklist. Local first-batch invalidation, cleanup and safe bounds are still required now.
M1 snapshot-difference history remains a separate follow-up. M2 journals/transactions, own orders and wealth reconciliation follow wallet balances as separately reviewed vertical slices. M3–M8 remain in [the roadmap](roadmap.md); do not create speculative implementation worktrees for them in this batch.
## Task handoff checklist
When assigning a task, include this document, its T-number, the roadmap's shared rules/definition of done, the exact integration-base commit and merged dependency commits. The agent should report:
1. Owned files and any shared-file patches needing a merge reservation.
2. Unresolved decisions requiring operator input before implementation.
3. Request/result contracts, schema migration and rollback impact.
4. Test commands/results, resource limits and isolation evidence.
5. Remaining blockers and a concise handoff for dependent tasks.
A task is complete only when its acceptance evidence survives integration—not when its worktree happens to pass alone.
+4 -4
View File
@@ -21,11 +21,11 @@ bun workers/reference.ts --production
bun workers/reference.ts --production --offline --archive-dir data/reference-live
```
The default production archive and artifact directory is `data/reference-live` (ignored by git); production publishes `data/reference-live/manifest.json`, while fixture builds remain under `data/reference`. An explicit `--directory` wins over `REFERENCE_DIR`, which wins over these defaults. `REFERENCE_LIVE_DIR` controls archive storage only. Compose explicitly publishes to `/app/data/reference`, the shared worker/web volume; the production web reader rejects fixture-profile artifacts. Runtime images must provide the `xz` executable (Debian package `xz-utils`) for streaming `.tar.xz` decompression; ZIP deflate uses the Bun runtime. No npm archive dependency is required. Production validation requires more than 5,000 systems and currently imports CCP's `mapSolarSystems`, `mapConstellations`, `mapRegions`, and `mapStargates` JSONL members.
The default production archive and artifact directory is `data/reference-live` (ignored by git); production publishes `data/reference-live/manifest.json`, while fixture builds remain under `data/reference`. An explicit `--directory` wins over `REFERENCE_DIR`, which wins over these defaults. `REFERENCE_LIVE_DIR` controls archive storage only. Compose explicitly publishes to `/app/data/reference`, the shared worker/web volume; the production web reader rejects fixture-profile artifacts. Runtime images must provide the `xz` executable (Debian package `xz-utils`) for streaming `.tar.xz` decompression; ZIP deflate uses the Bun runtime. No npm archive dependency is required. Production validation requires more than 5,000 systems, at least one station, and imports CCP's `mapSolarSystems`, `mapConstellations`, `mapRegions`, `mapStargates`, and `npcStations` JSONL members. A production candidate missing `npcStations.jsonl` or containing no station rows fails closed rather than claiming any station rows. The importer parses official station ID/system/type/owner/position facts; names remain unknown unless a separately verified name-bearing adapter is selected. The production profile records station coverage as explicitly `incomplete` until a measured release replay proves source-consistent coverage; `stationsMinimum: 1` is only a non-empty safety gate, not a completeness claim. The current worktree does not download or promote a production artifact, so deployed station coverage remains unverified until an isolated replay validates the member, its joins, and its measured count.
`fixtures/reference/` contains small schema-faithful EVE Ref keyed records for categories, groups, types, and blueprints (including types 34 Tritanium, 35 Pyerite, 587 Rifter, and blueprint 687). The Rifter recipe is synthetic and is labelled as such. Hoboleaks `industryactivities`, `repackagedvolumes`, and `meta` are independent raw maps. `systems.json` is explicitly an ESI-universe fixture: it preserves the official system fields and XYZ coordinate object and is not presented as a Reference Data `/systems` record.
The fixture profile is intentionally small. It uses a one-system threshold and required categories 4, 6, and 9. Those thresholds are never silently used for production: production validation requires more than 5,000 systems, categories 4/6/9, and at least one blueprint. On the inspected 2026-09-13/2026-09-10 releases, the production candidate contained 48 categories, 1,610 groups, 53,001 types, 5,082 blueprints, 8,490 systems, 1,184 constellations, 114 regions, and 13,978 gates. Twenty-one official blueprint material/product IDs are absent from the current Ref type map; those rows are preserved and explicitly listed in `unresolved_references` rather than dropped or fabricated.
The fixture profile is intentionally small. It uses a one-system threshold and required categories 4, 6, and 9. Those thresholds are never silently used for production: production validation requires more than 5,000 systems, at least one station, categories 4/6/9, and at least one blueprint. On the inspected 2026-09-13/2026-09-10 releases, the production candidate contained 48 categories, 1,610 groups, 53,001 types, 5,082 blueprints, 8,490 systems, 1,184 constellations, 114 regions, and 13,978 gates. Twenty-one official blueprint material/product IDs are absent from the current Ref type map; those rows are preserved and explicitly listed in `unresolved_references` rather than dropped or fabricated.
## Active artifact and read handles
@@ -46,7 +46,7 @@ try {
}
```
Construction snapshots the manifest and opens that database read-only. It performs only a cheap schema/header check; full SQLite integrity and foreign-key scans belong to the compiler and are not repeated on every request. It therefore keeps `types()` and `version()` consistent if another process promotes a build. Type lookups chunk large ID lists into bounded `IN` queries. Create one repository (or `repository.pin()`/`snapshot()`) per request and call `close()` in `finally`; do not retain a closed handle. A new constructor sees the next active artifact.
Construction snapshots the manifest and opens that database read-only. It performs only a cheap schema/header check (including the versioned reader schema, FTS5 type-search table, and required normalized-name index); full SQLite integrity and foreign-key scans belong to the compiler and are not repeated on every request. It therefore keeps `types()` and `version()` consistent if another process promotes a build. Type lookups chunk large ID requests into bounded `IN` queries; category/group/location ID selections reject oversized batches visibly. Create one repository (or `repository.pin()`/`snapshot()`) per request and call `close()` in `finally`; do not retain a closed handle. A new constructor sees the next active artifact. A prior schema-version artifact is rejected until a compatible compiler rebuilds it; rollback validates schema/profile/importer/count/provenance compatibility before swapping immutable artifacts.
## Promotion lifecycle
@@ -59,7 +59,7 @@ The lock prevents concurrent builds. A crash can leave a lock, staging directory
## Rollback and checks
```sh
REFERENCE_DIR=data/reference bun workers/reference.ts --rollback
REFERENCE_DIR=data/reference bun workers/reference.ts --fixture --rollback
REFERENCE_DIR=data/reference bun workers/reference.ts --check --fixture
bun workers/reference.ts --help
```
+132
View File
@@ -0,0 +1,132 @@
# Release and rollout safety (T01)
This repository uses Gitea Actions for CI. The workflow in `.gitea/workflows/ci.yml`
installs the frozen lockfile, runs local tests/check/build, compiles the
checked-in synthetic reference fixture, runs an isolated demo web smoke, and
prepares a separate Docker/Compose artifact job. It does not receive
deployment credentials, trigger a remote deployment, or write a persistent
Convex backend.
## Server-side rollout gate
`APP_CAPABILITY_ALLOWLIST` is a comma-separated deployment setting parsed by
`src/lib/server/release-gate.ts`. An omitted setting preserves the existing
private asset/sign-in baseline (`asset_search`, `asset_valuation`,
`asset_sync`, `assistant_dispatch`, `session_token`, `identity`, and
`subscriptions`). An explicitly empty
setting disables all capabilities. Unknown names fail configuration rather
than becoming an accidental opt-in; known capabilities that are not in the
baseline (for example `wallet_balance`, `inventory_export`, and
`manufacturing_opportunities`) are default-deny.
The web server checks the allowlist at the relevant boundary:
- `/api/chat` checks `assistant_dispatch` before invoking manual or external
intent routing.
- `/assets` and assistant execution check the selected asset capability plus
`asset_valuation`, because the current shared report intentionally returns a
reference valuation even for browse results.
- `/api/sync` and the application synchronizer check `asset_sync`.
- `/api/session/token` checks `session_token`.
- Character management and OAuth entry points check `identity`.
- The root layout skips its authenticated session lookup only when every
capability represented by the root assistant/navigation shell is disabled;
if a separate asset, identity, session-token, subscription, or assistant
capability remains enabled, it preserves the existing user session state.
- The Convex browser status subscription checks `subscriptions`; sync bridge
mutations check `asset_sync` in the backend authorization plane too. The
allowlist is copied into Convex by `bun run convex:configure`; that setting
is required there rather than inferred from the web container.
This is an admission control mechanism, not a UI feature flag. Disabled
operations return an explicit unavailable/503 response and do not invoke a
provider. The current character status subscription is gated in Convex; no
durable user-job service exists in this baseline, so future job kinds remain
closed until they have reviewed enqueue and worker entry points.
### Policy authority and update order
The web and Convex processes receive the same value, but parse it in separate
processes. The web parser preserves the private baseline when its setting is
omitted for development compatibility; the Convex/backend parser requires an
explicit, valid setting and fails closed when propagation is missing, empty,
or malformed. Convex is authoritative for persisted subscriptions and bridge
mutations; the web gate is an earlier ingress/provider guard, not a replacement
for backend authorization.
Update the backend setting first, validate a status query and representative
bridge mutation in an isolated deployment, then update the web setting and
verify the HTTP gate. A web-only narrowing does **not** revoke existing
Convex subscriptions, sessions, or grants, and this lane makes no such claim.
A backend-only narrowing may cause already-running web requests to receive
backend denials until the web setting converges. Record both settings and the
validation timestamps in the release evidence; rollback must restore the
backend policy before reopening web entry points.
## Immutable release metadata
`APP_RELEASE_ID` is carried into the runtime image and returned as the
non-secret `x-application-release` response header. Local/fixture runs use
`local` or `fixture`. Staging and production must provide a non-placeholder
identifier (normally the source commit or an operator-approved image digest).
`bun run release:check` requires an explicit `RELEASE_ENV` for live runs and
rejects placeholder staging/production identifiers, unpinned staging/production
backend/dashboard images or Dockerfile bases, demo mode in staging/production,
invalid capability names, tracked secret-like files, and managed secret names
in built browser artifacts. The Dockerfile uses digest-pinned Bun bases and
accepts `--build-arg APP_RELEASE_ID=...`; Compose passes the runtime value
explicitly.
## Isolated local validation
Use a separate worktree-local output directory and a unique Compose project
name for backend experiments. Never point these commands at a persistent
backend or copy a live `.env`/volume:
```sh
APP_MODE=demo APP_RELEASE_ID=fixture bun run build
APP_MODE=demo APP_RELEASE_ID=fixture bun run release:check
APP_MODE=demo APP_RELEASE_ID=fixture bun run test:fixture
```
`test:fixture` compiles the checked-in reference sources into a temporary
folder, starts the built server on loopback with synthetic data, checks the
release header and matching `/assets`/chat totals, then kills the server and
removes the temporary artifact directory.
For an isolated Compose rehearsal, set `COMPOSE_PROJECT_NAME` to a unique
throwaway value, keep all binds on loopback, use a fixture/demo environment,
and remove the project only after recording the result. The release gate does
not itself perform migrations, backups, restores, or cleanup of an operator's
persistent deployment.
## Restore and migration evidence
The synthetic restore/migration rehearsal is specified in
[`docs/restore-rehearsal.md`](restore-rehearsal.md). It includes Convex data
and instance-credential backup/restore, application rollback, migration
rollback prerequisites, session/grant invalidation decisions, resource
budgets, and a redacted evidence template. It has not been executed in this
lane.
## Evidence boundaries
Read-only `tea` inspection on 2026-09-19 reported no Gitea workflows or runs
for `jb/eden`; runner labels, Docker availability, protected-ref enforcement,
and action availability were therefore not validated. The workflow is prepared
locally but has not run remotely. The source job runs a strict staging release
check after building the client. The separate container job is currently
**disabled** (`if: ${{ false }}`), not merely push-filtered: its rootless
runner and protected-ref policy require operator verification and approval
before enablement. The local infrastructure plan mentions `personal-isolated`,
`ubuntu-latest`, and `ubuntu-22.04` labels, but those are not confirmed
registered labels. The plan describes a dedicated runner VM with a
Docker-socket-backed daemon; rootless execution, online status, action
availability, and protected refs remain unverified. Its draft commands use
per-run image, volume, and container names and clean up only those names, but
are not release evidence while the job is disabled. No browser automation,
OAuth callback failure injection, Convex
migration rollback, instance-credential backup/restore, key rotation, or live
revocation/transfer evidence is claimed by this lane. Those remain explicit
operator/integration gates and must use synthetic or separately consented
isolated environments.
+12 -3
View File
@@ -162,7 +162,15 @@ The list is metadata-driven, not a hard-coded schema promise: if a future `meta.
Hoboleaks metadata also carries per-file `md5`, `revision`, `stale`, and `deprecated`. A file can be retrievable but stale. Record this as source state; do not convert it to a transport-successful fresh input.
## 4. Universe XYZ source choice
## 4. NPC station source and explicit name coverage
The CCP JSON Lines SDE includes `npcStations.jsonl`; CCP's [schema changelog](https://developers.eveonline.com/static-data/tranquility/schema-changelog.yaml) records the station-schema migration and the [Map Data guide](https://developers.eveonline.com/docs/guides/map-data/) establishes the map hierarchy. A bounded inspection of the build-3503375 station member found 5,210 records with `_key`, `solarSystemID`, `ownerID`, `typeID`, and nested `position` (`x`, `y`, `z`); none carried a `name` field. The third-party schema page was used only as a cross-check, not as authority; this worktree did not download or promote the production archive.
The importer therefore treats CCP station IDs, system relationships, owner/type IDs, and coordinates as source facts, while station names remain unknown for the official SDE. The normalized station contract permits an optional `name` only when a separately verified name-bearing adapter supplies it; the production CCP adapter ignores all name-like fields and never derives a name from an ID or type. A station record must reference a system in the same candidate; malformed position/IDs or mismatched supplied system station lists fail closed. The selected production CCP parser requires `npcStations.jsonl` and the production validation profile requires at least one verified station row; a missing member or empty station set fails closed instead of degrading to fabricated coverage. This is explicitly an incomplete station-coverage profile until an isolated replay measures a source-consistent release count; it is not a universal completeness threshold.
This worktree has not downloaded or replayed the production ZIP, so actual release member presence and station row coverage remain an acceptance blocker for deployed NPC station coverage.
## 5. Universe XYZ source choice
### Comparison
@@ -196,7 +204,7 @@ The real line has additional fields. Preserve unknown fields or explicitly ignor
A current ESI list request returned 8,490 IDs (checked 2026-09-18), confirming that a production profile limited to synthetic fixtures or a handful of systems is not sufficient. The CCP SDE map file is the efficient immutable complement: one current archive download rather than thousands of ESI calls.
## 5. Download and streaming plan (after this phase)
## 6. Download and streaming plan (after this phase)
### Preflight
@@ -221,7 +229,7 @@ The ESI-scrape alternative would total 92,227,356 bytes with Reference Data + Ho
1. Create a unique staging directory and `.part` file per source. Stream each HTTP body to disk while hashing and counting; do not place bytes in a JS/Bun `Buffer` for the whole archive.
2. Close and verify the staged file before opening it. Keep the immutable staged artifact until the candidate DB and manifest validate.
3. For Reference Data and Hoboleaks, stream XZ → TAR sequentially. Parse each selected root member as it arrives; close the TAR/XZ reader on any error. These archives are small enough to retain on disk but not to trust as an in-memory blob.
4. For CCP SDE JSONL, keep the completed ZIP staged, inspect the ZIP central directory, and stream only `mapSolarSystems.jsonl` through the JSON Lines parser. Do not extract the whole ~99 MB ZIP merely to build the universe complement. Keep the ZIP hash in provenance even if only one member is consumed.
4. For CCP SDE JSONL, keep the completed ZIP staged, inspect the ZIP central directory, and stream only `mapSolarSystems.jsonl`, `mapConstellations.jsonl`, `mapRegions.jsonl`, `mapStargates.jsonl`, and `npcStations.jsonl` through the JSON Lines parser. Do not extract the whole ~99 MB ZIP merely to build the universe complement. Keep the ZIP hash in provenance even though only selected members are consumed.
5. Parse JSONL line-by-line: reject malformed lines, require integer `_key`, require finite numeric `position.x/y/z`, reject duplicate IDs, and count systems. Preserve the full source line or unknown fields if later features may need them.
6. Build a new immutable SQLite candidate, validate all cross-source references and the real universe count (expected to be >5,000), close it, then atomically publish its manifest. Keep the previous manifest/DB untouched on any source or validation failure.
@@ -253,6 +261,7 @@ A successful Reference Data archive must not mask a failed SDE universe compleme
- [EVE Ref `ScrapeHoboleaks.java`](https://github.com/autonomouslogic/eve-ref/blob/17948f63ad35b3417ad888a72f6d4fcc49e2b72d/src/main/java/com/autonomouslogic/everef/cli/ScrapeHoboleaks.java) — metadata-driven root member creation.
- [Hoboleaks `tq/meta.json`](https://sde.hoboleaks.space/tq/meta.json) — current revision, file list, MD5, stale/deprecated state.
- [CCP Static Data docs](https://developers.eveonline.com/docs/services/static-data/) — JSONL format, automation routes, build metadata, HTTP caching.
- [CCP schema changelog](https://developers.eveonline.com/static-data/tranquility/schema-changelog.yaml) and [Map Data guide](https://developers.eveonline.com/docs/guides/map-data/) — station-schema migration and station/system/region hierarchy evidence.
- [CCP current build metadata](https://developers.eveonline.com/static-data/tranquility/latest.jsonl) and [build 3503375 JSONL release](https://developers.eveonline.com/static-data/tranquility/eve-online-static-data-3503375-jsonl.zip) — current build and release size.
- [CCP Map Data guide](https://developers.eveonline.com/docs/guides/map-data/) — `mapSolarSystems.jsonl`, `_key`, XYZ fields, all-space coverage, and coordinate precision.
- [CCP System Security guide](https://developers.eveonline.com/docs/guides/system-security/) — SDE system source and `securityStatus` field.
+350
View File
@@ -0,0 +1,350 @@
# Isolated application/Convex restore rehearsal (T01)
This is a runbook and evidence template, not an executed restore. It is
intended for a disposable self-hosted Convex deployment and synthetic users
only. Never point it at the persistent development deployment, copy a live
`.env`, or use production Infisical credentials.
## Scope and ownership
T01 owns the application/Compose release procedure, Convex data and instance
credential backup/restore, configuration/key backup handling, and post-restore
session/grant invalidation evidence. T03 owns immutable reference artifact
rollback and profile-compatible `PREVIOUS` handling; this runbook does not
change or claim that path.
The current repository has no automated Convex migration rollback command and
no implemented global post-restore session/grant invalidation migration. Those
are explicit blockers below, not steps that may be silently skipped.
## Prerequisites and isolation
- A clean T01 worktree at a recorded commit; Bun 1.3.14 and Docker/Compose.
- A unique project name, for example `eden-restore-$USER-$RANDOM`, unique
volume names, and unique loopback ports. Keep all published ports on
loopback and use a disposable backend.
- Free disk equal to **source volume bytes + backup archive bytes + restored
volume bytes + 20% headroom**. The archive size is unknown until backup, so
record `du -sb` before starting and recheck before extraction; a simple 2x
estimate is not sufficient.
- Budget **2 CPUs, 4 GiB RAM, and 30 minutes** for the disposable backend and
application rehearsal. A reference rebuild is a separate T03 operation;
the reviewed production worker budget is 2 CPUs/4 GiB and must not be
assumed as Convex restore evidence.
- A temporary mode-0600 directory outside the repository for synthetic keys,
backup archives, logs, and evidence. Do not print key values or put them in
command arguments.
- Host root access for direct Docker-volume `tar` operations, or a separately
approved, digest-pinned helper image. The commands below use direct host
tar and must be run only on the disposable volumes.
```bash
#!/usr/bin/env bash
set -euo pipefail
repo="$PWD"
project="eden-restore-${USER:-operator}-$(date +%s)-$$"
restore_project="${project}-restored"
work="$(mktemp -d "${TMPDIR:-/tmp}/eden-restore.XXXXXX")"
chmod 700 "$work"
# Do not let an ambient shell, repository .env, or prior Compose project alter
# this rehearsal. The explicit env file below contains only non-secret values;
# synthetic key values are loaded separately from the mode-0600 key file.
unset COMPOSE_FILE COMPOSE_ENV_FILES COMPOSE_PROJECT_NAME COMPOSE_PROFILES DOCKER_HOST APP_MODE APP_ORIGIN ORIGIN APP_RELEASE_ID APP_CAPABILITY_ALLOWLIST CONVEX_URL PUBLIC_CONVEX_URL CONVEX_SITE_ORIGIN CONVEX_PORT CONVEX_SITE_PORT WEB_PORT CONVEX_IMAGE CONVEX_DASHBOARD_IMAGE EVE_CALLBACK_URL EVE_CLIENT_ID EVE_CLIENT_SECRET ESI_USER_AGENT TYPESAFE_API_KEY APP_AUTH_ISSUER TOKEN_ENCRYPTION_KEY CONVEX_BRIDGE_SECRET CONVEX_SYNC_WORKER_SECRET APP_AUTH_PRIVATE_KEY APP_AUTH_JWKS CONVEX_SELF_HOSTED_URL CONVEX_SELF_HOSTED_ADMIN_KEY
export CONVEX_BIND_ADDRESS=127.0.0.1 CONVEX_PORT=$((3200 + RANDOM % 500)) CONVEX_SITE_PORT=$((3700 + RANDOM % 500))
export WEB_BIND_ADDRESS=127.0.0.1 WEB_PORT=$((4200 + RANDOM % 500))
export APP_MODE=live APP_RELEASE_ID=restore-fixture APP_CAPABILITY_ALLOWLIST=asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions
export APP_ORIGIN="http://127.0.0.1:$WEB_PORT" ORIGIN="$APP_ORIGIN" APP_AUTH_ISSUER="$APP_ORIGIN"
export PUBLIC_CONVEX_URL="http://127.0.0.1:$CONVEX_PORT" CONVEX_SITE_ORIGIN="http://127.0.0.1:$CONVEX_SITE_PORT"
export EVE_CALLBACK_URL="$APP_ORIGIN/auth/eve/callback"
write_compose_env() {
cat > "$work/compose.env" <<EOF
COMPOSE_PROJECT_NAME=$project
CONVEX_BIND_ADDRESS=127.0.0.1
CONVEX_PORT=$CONVEX_PORT
CONVEX_SITE_PORT=$CONVEX_SITE_PORT
WEB_BIND_ADDRESS=127.0.0.1
WEB_PORT=$WEB_PORT
APP_MODE=$APP_MODE
APP_RELEASE_ID=$APP_RELEASE_ID
APP_CAPABILITY_ALLOWLIST=$APP_CAPABILITY_ALLOWLIST
APP_ORIGIN=$APP_ORIGIN
ORIGIN=$ORIGIN
APP_AUTH_ISSUER=$APP_AUTH_ISSUER
PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
CONVEX_SITE_ORIGIN=$CONVEX_SITE_ORIGIN
EVE_CALLBACK_URL=$EVE_CALLBACK_URL
EOF
}
write_compose_env
compose() { docker compose --project-directory "$repo" --file "$repo/compose.yaml" --env-file "$work/compose.env" "$@"; }
cleanup() {
compose -p "$project" down -v >/dev/null 2>&1 || true
compose -p "$restore_project" down -v >/dev/null 2>&1 || true
# Export/copy the evidence bundle before this cleanup; the temporary key and
# archives are intentionally removed only after retention is recorded.
rm -rf "$work"
}
trap cleanup EXIT
(cd "$work" && bun "$repo/scripts/generate-keys.ts")
chmod 600 "$work/.env.keys"
set -a
. "$work/.env.keys"
set +a
```
The bootstrap above loads only synthetic values into a temporary shell
environment and writes the explicit Compose env file. It allocates loopback
backend, site, web, issuer, public URL, and callback origins together; when
restore ports change, update all corresponding exported values before starting
the restored project. The Compose web service intentionally uses the
service-internal `http://backend:3210`; only host/public settings use the
allocated loopback ports. Do not use a production callback or provider key.
## Backup rehearsal
1. Start a disposable backend with a unique Compose project and record the
backend image digest, git commit, volume name, and container ID.
```sh
export COMPOSE_PROJECT_NAME="$project"
compose -p "$project" up -d backend
volume="${project}_convex-data"
mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$volume")"
source_bytes_before="$(du -sb "$mount" | awk '{print $1}')"
printf 'source_bytes_before=%s\n' "$source_bytes_before" > "$work/volume-metrics.txt"
```
2. Obtain the disposable instance admin key through the operator-approved
secret mechanism for the exact pinned `CONVEX_IMAGE`; hold it in memory
only. Configure the synthetic Convex environment using the temporary key
file, synthetic issuer/JWKS, bridge secret, and allowlist. Do not invent a
key fingerprint or assume an image-specific helper command:
```sh
read -r -s admin_key
CONVEX_SELF_HOSTED_ADMIN_KEY="$admin_key" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/configure-convex.ts" >/dev/null
```
Use the existing configure path only with this disposable URL/admin key;
never pass a persistent admin key through this procedure. Successful
configuration against the restored instance using this same in-memory key
is the credential continuity check.
3. The legacy `scripts/smoke-convex.ts` exercises useful synthetic writes,
but currently accepts an arbitrary `CONVEX_SELF_HOSTED_URL` when write is
enabled. Do **not** execute it as part of this runbook; it remains
unexecuted evidence until it is wrapped with the same loopback target,
explicit disposable confirmation, and key scoping as the marker tool.
Then write a deterministic marker that can be looked up after restore. Use
a fresh marker and synthetic character ID for every rehearsal; do not use a
random-only smoke result as proof of record survival:
```sh
export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic"
export RESTORE_MARKER_CHARACTER_ID=4000000001 # reserved synthetic range
export RESTORE_MARKER_SESSION_EXPIRES_AT=$(( $(date +%s) * 1000 + 7 * 24 * 60 * 60 * 1000 ))
SMOKE_ALLOW_WRITE=1 RESTORE_MARKER_CONFIRMATION=DISPOSABLE_LOOPBACK_ONLY \
RESTORE_MARKER_PHASE=write RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log"
```
The marker tool defaults to non-writing verification, rejects non-loopback
targets before constructing a backend client, and requires both the explicit
write phase and `DISPOSABLE_LOOPBACK_ONLY` confirmation. Never reuse the
marker command against a shared, staging, or production URL.
4. Create a consistent filesystem backup while the disposable backend is
stopped. The Convex data volume contains the backend data and instance
credential material; back up it as one unit. A host administrator may need
root access to the Docker volume mount.
```sh
compose -p "$project" stop backend
tar --xattrs --acls --numeric-owner -C "$mount" \
-czf "$work/convex-data.tar.gz" .
sha256sum "$work/convex-data.tar.gz" > "$work/convex-data.tar.gz.sha256"
archive_bytes="$(stat -c '%s' "$work/convex-data.tar.gz")"
printf 'archive_bytes=%s\n' "$archive_bytes" >> "$work/volume-metrics.txt"
cp --preserve=mode "$work/.env.keys" "$work/application-keys.synthetic"
sha256sum "$work/application-keys.synthetic" > "$work/application-keys.synthetic.sha256"
```
Record archive byte size, SHA-256, volume size, and stop/start timestamps.
Do not include raw keys in the evidence bundle; retain the mode-0600 file in
the protected temporary directory until cleanup. Before extraction, require
free disk of at least `source_bytes_before + archive_bytes + estimated
restored_bytes + 20% headroom`.
## Restore and isolation checks
1. Create a second unique Compose project and volume. Extract only the
recorded archive into the new volume, preserving ownership and xattrs.
```sh
restore_volume="${restore_project}_convex-data"
docker volume create "$restore_volume" >/dev/null
restore_mount="$(docker volume inspect -f '{{ .Mountpoint }}' "$restore_volume")"
tar --xattrs --acls --numeric-owner -C "$restore_mount" \
-xzf "$work/convex-data.tar.gz"
export CONVEX_PORT=$((5200 + RANDOM % 500)) CONVEX_SITE_PORT=$((5700 + RANDOM % 500)) WEB_PORT=$((6200 + RANDOM % 500))
export APP_ORIGIN="http://127.0.0.1:$WEB_PORT" ORIGIN="$APP_ORIGIN" APP_AUTH_ISSUER="$APP_ORIGIN"
export PUBLIC_CONVEX_URL="http://127.0.0.1:$CONVEX_PORT" CONVEX_SITE_ORIGIN="http://127.0.0.1:$CONVEX_SITE_PORT"
export EVE_CALLBACK_URL="$APP_ORIGIN/auth/eve/callback"
write_compose_env
```
2. Start the restored backend under the second project and configure the web
container with **only** the synthetic application keys. Reuse the same
in-memory `CONVEX_SELF_HOSTED_ADMIN_KEY` captured before backup; the
configure command must succeed against the restored URL. Failure means
instance-credential continuity is unproven and traffic must remain closed:
```sh
compose -p "$restore_project" up -d backend
CONVEX_SELF_HOSTED_ADMIN_KEY="$admin_key" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
bun "$repo/scripts/configure-convex.ts" >/dev/null
```
3. Verify the deterministic marker before any other restore evidence. The
legacy synthetic Convex smoke remains unexecuted for the safety reason
above; do not substitute its arbitrary-target behavior for restore proof:
```sh
RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts"
```
Verify:
- restored schema/functions start without a migration error;
- a newly created synthetic user cannot read the source project's records;
- restored records remain tenant-isolated;
- the deterministic pre-backup marker is present and unchanged;
- the old pre-backup session/JWT is rejected after the approved invalidation
step; and
- the old encrypted grant cannot service synchronization until explicit
reauthorization.
Reference-volume separation, read-only web mounting, and reference artifact
rollback are outside this procedure and belong to T03's separate rehearsal.
Do not record those as T01 restore evidence.
4. **Current blocker:** the repository does not yet provide a global
post-restore session/grant invalidation command. Do not claim this check
passed. The operator must choose and approve one of: a Convex migration that
revokes all sessions and quarantines token records, a deployment-specific
credential-version fence, or an equivalent bounded mechanism. Record the
maximum stale-authority window and the decision before reopening traffic.
If implemented for the rehearsal, run the marker check before its explicit
expiry window:
```sh
RESTORE_MARKER="$RESTORE_MARKER" RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
RESTORE_MARKER_PHASE=expect-invalidated bun "$repo/scripts/smoke-restore-marker.ts"
```
Grant rotation/quarantine behavior is unexecuted until that mechanism
exists; no synthetic provider result can substitute for it.
5. Test application rollback by stopping the restored web service, starting the
immediately previous digest-pinned web image against the restored backend,
and exercising read-only synthetic status/assets paths. Do not use a mutable
`latest` tag. If the schema is not backward compatible, record rollback as
blocked; do not mutate the source volume to make it pass.
## Migration rollback rehearsal
There is no migration history/rollback command in this baseline. A valid
rehearsal therefore requires a parent-approved migration fixture and a chosen
rollback mechanism. Before starting, record the exact **baseline commit/image
and candidate commit/image**, the migration command, the backend image digest,
and the compatibility contract. The evidence must include commands equivalent
to these (with the operator's approved migration tool substituted explicitly):
```sh
baseline_commit=$(git rev-parse HEAD)
candidate_commit=<recorded-candidate-commit>
baseline_image=<recorded-digest-pinned-image>
candidate_image=<recorded-digest-pinned-image>
# approved migration command against the disposable candidate deployment:
<deploy-candidate-and-run-migration>
# restore pre-migration archive into a fresh volume/project:
<restore-pre-migration-volume>
# start baseline image against the restored volume:
<deploy-baseline-image-and-run-compatibility-checks>
```
At minimum:
1. back up the disposable volume before migration;
2. deploy the candidate migration to the disposable backend;
3. run schema/functions and tenant-isolation checks;
4. restore the pre-migration volume into a fresh project (never in place);
5. start the previous application image and rerun the checks; and
6. record whether the migration is backward compatible and which data loss,
RPO, and RTO limits apply.
Placeholder commands are an evidence gap, not a passing rehearsal. A
`docker compose down -v` or a successful process restart is not migration
rollback evidence. T03's reference artifact rollback is a separate test and
must verify profile, importer version, manifest integrity, and live-reader
compatibility.
## Retention and cleanup
Before leaving the shell (and before the `trap` removes `$work`), export only
redacted logs, checksums, metrics, image digests, and the evidence table to a
protected location outside the temporary directory. Never copy either key
file or an admin key into that bundle:
```sh
evidence_dir="${EVIDENCE_DIR:?Set an approved protected evidence directory}"
umask 077
mkdir -p "$evidence_dir/$project"
cp "$work"/*.log "$work"/*.sha256 "$work"/*metrics* "$evidence_dir/$project/" 2>/dev/null || true
printf 'source_project=%s\nrestored_project=%s\n' "$project" "$restore_project" > "$evidence_dir/$project/projects.txt"
sha256sum "$evidence_dir/$project"/* > "$evidence_dir/$project/evidence.sha256"
printf 'retained_until_utc=<operator-set-expiry>\n' > "$evidence_dir/$project/retention.txt"
```
Confirm the bundle contains no `.env`, `application-keys`, admin key, token,
or unredacted provider output. Only after export and operator approval may the
cleanup trap remove both Compose projects, both volumes, and `$work`.
## Evidence template
Copy this table into the release record and attach only redacted logs/checksums:
| Field | Record |
|---|---|
| Worktree commit / image digests | |
| Source and restored Compose project names | |
| Convex source/restored volume names | |
| Synthetic key IDs/fingerprints (not values) | |
| Backup archive bytes / SHA-256 | |
| Pre/post volume byte counts | |
| Schema/migration version and rollback mechanism | |
| Session/grant invalidation decision and stale window | |
| Commands and timestamps (UTC) | |
| Synthetic smoke result | |
| Tenant-isolation result | |
| Old-session rejection result | |
| Old-grant sync rejection result | |
| Browser/provider/restore steps not exercised | |
| Cleanup confirmation and retained artifacts/expiry | |
| Operator approval and unresolved blockers | |
The evidence must label mock, fixture, disposable-backend, and consenting-live
observations separately. Passing local tests, CI, or a fixture container does
not constitute a restore, browser, provider, or production-readiness claim.
+34 -20
View File
@@ -1,29 +1,34 @@
# Implementation roadmap
**Status:** planning proposal, not implementation or a public-launch declaration.
**Baseline:** the operator has verified real EVE sign-in, asset synchronization, and matching Assets/chat reference valuations. The latest implementation gate has 97 passing tests.
**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
1. **Inventory and personal finances deliver the next user-visible value.**
2. **Eventually support open public signup**, not just a private installation. Public signup does not make anybody's character data public.
3. **Keep EVE-only sign-in.** Explicitly linked characters remain attached to one application user; an owner hash is never an EVE-account grouping key.
4. **Include corporation features later**, behind a separate authorization/sharing design.
5. Retain Bun/TypeScript, SvelteKit/Svelte, Convex for application state, immutable reference SQLite, Infisical, and single-host Compose initially. Do not introduce another database or model provider without a measured need and a separate decision.
6. This roadmap is **read-only intelligence and planning**: no automatic market orders, ISK transfers, asset moves, industry-job submission, or ship movement.
2. **Work locally first.** The project is pre-production and currently has no users beyond the operator's test character. Functional local flows are the next gate; production-readiness evidence is deliberately deferred until the operator approves the prototype. Before public launch, wiping/reseeding development data is preferred over adding costly migration/retention compatibility. This exception ends at production/public launch: production migration, retention and rollback gates remain mandatory before P.
3. **Keep EVE-only sign-in.** One linked character is the sole primary/site-login character. Additional linked characters are data-only and cannot authenticate signed out; an authenticated user may switch the sole primary. An owner hash is never an EVE-account grouping key.
4. **Eventually support open public signup**, not just a private installation. Public signup does not make anybody's character data public.
5. **Include corporation features later**, behind a separate authorization/sharing design.
6. Retain Bun/TypeScript, SvelteKit/Svelte, Convex for application state, immutable reference SQLite, Infisical, and single-host Compose initially. Do not introduce another database or model provider without a measured need and a separate decision.
7. This roadmap is **read-only intelligence and planning**: no automatic market orders, ISK transfers, asset moves, industry-job submission, or ship movement.
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/T08 prototype checkpoint
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
| Area | Current implementation | Important gap |
|---|---|---|
| Identity | Verified EVE SSO/PKCE, encrypted grants, explicit linking, opaque sessions, app JWTs | Recovery, unlink/delete, transfer lifecycle, incremental scopes, session management, multi-character live tests |
| Identity | Verified EVE SSO/PKCE, encrypted grants, one primary login character, data-only linked characters, authenticated primary switching, opaque sessions, app JWTs | Recovery, unlink/delete, transfer lifecycle, production session management, multi-character live tests |
| 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 | On-demand work inside web requests; generation publication and token CAS | `jobs` is schema-only; no durable dispatcher, crash recovery, shared refresh lease or global worker budgets |
| 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 |
@@ -34,6 +39,14 @@ New service/table/path names below are proposed implementation boundaries, not e
## 3. Recommended delivery order
### Local-prototype phase (before production-readiness work)
1. **Functional local prototype:** implement and exercise first login, authenticated linking, capability-selected consent/upgrades, reduced-grant invalidation, primary switching, cancellation, asset sync/read fencing and tenant isolation using disposable Convex data.
2. **Prototype approval:** the operator decides that the local flows are useful and stable enough to justify operational investment.
3. **Production-readiness phase:** only after approval, add CI/deployed-artifact proof, browser failure injection, restore/migration rollback rehearsals, retention/GC, public quotas/admission, lifecycle recovery, and other P/release evidence.
Essential correctness and security boundaries remain in the prototype: server-derived authorization, tenant isolation, scope allowlists, one-time OAuth, no token leakage, fail-closed owner checks and no blind OAuth-code replay. Prototype work must not grow production operational machinery prematurely.
| Milestone | User-visible outcome | Dependencies | Relative size |
|---|---|---|---|
| M0 — Reliable account/data foundation | Clear connection/sync status; safe upgrades and recovery | Current baseline | L |
@@ -98,12 +111,12 @@ Keep valuation methods distinct. Never quietly replace an average price with an
### Deliverables / work packages
**M0.1 — Reproducible release and safety baseline**
- Review secret exclusions and establish an operator-approved version-controlled baseline; no commit is performed by this planning work.
- Add isolated CI for tests/typecheck/build, deployed-artifact checks, migrations and browser flows.
- Add durable browser regression coverage for native POST forms, OAuth redirect/cookie paths, singleton/array scopes, linking, logout and pending-request resume. Use synthetic providers/users only in isolated test deployments, never the persistent live backend.
- Version application images; keep backwards-compatible migrations and an explicit rollback procedure.
- Define and implement server-side rollout gating; it does not exist today. A deployment configuration/capability allowlist may suffice initially, but the mechanism needs approval and tests. Gates must protect routes, assistant dispatch, jobs and subscriptions—not merely hide UI controls.
**M0.1 — Local prototype baseline (production evidence deferred)**
- Review essential secret exclusions and establish an operator-approved local baseline; no commit is performed by this planning work.
- Run local tests/typecheck/build as useful feedback, but defer CI, deployed-artifact proof, browser failure injection, migration/rollback rehearsal and public launch evidence until prototype approval.
- Exercise native forms, OAuth redirect/cookie paths, singleton/array scopes, linking, primary switching, cancellation, tenant isolation and pending-request behavior with disposable synthetic/local data.
- Because there are no production users, wipe/reseed local data when schema/auth changes make migration compatibility costly; do not add legacy migration/lazy-normalization machinery for prototype-only rows.
- Keep server-side rollout gating and essential auth boundaries. Durable jobs and any new capability still require their own reviewed entry points later.
**M0.2 — Dataset capabilities and incremental consent**
- Extend the model/gate/store contracts for dataset-specific scopes and freshness.
@@ -122,11 +135,12 @@ Keep valuation methods distinct. Never quietly replace an average price with an
- Keep heavy reference extraction in its separate bounded worker; publish small reference health metadata to Convex only. Give each worker only its required secrets/capabilities; reference extraction needs no EVE grants, JEV key, JWT signing key or deployment-admin credential.
**M0.4 — EVE-only identity lifecycle**
- Treat each linked character as a login credential under the existing model. Explain that anybody able to authenticate as one of those characters can access the application account: linking is not merely read-only data sharing. Test transfer, revocation, unlinking and loss of access.
- Require fresh proof through an already trusted login character for sensitive account actions; the new character being added is not sufficient step-up proof for adding a login credential. List/revoke sessions and detach credentials safely. Review host-bound cookie naming and test sibling-subdomain cookie injection/session fixation before public launch.
- Treat the first linked character as the sole login/primary credential under the existing model. Additional linked characters are data-only and cannot authenticate signed out; while authenticated, the user can designate another authorized linked character as the sole primary and the old primary is immediately demoted. Explain that anybody able to authenticate as the primary can access the application account: linking is not merely read-only data sharing.
- During the local prototype, test owner/revocation/cancellation fencing and primary switching. Full recovery, transfer, unlink/delete and production lifecycle evidence remain deferred until prototype approval.
- During the local prototype, an active authenticated application session is sufficient to link a data-only character or switch the primary. Revisit fresh proof for sensitive account actions, session listing/revocation, host-bound cookie naming and sibling-subdomain session-fixation tests during the post-prototype production-readiness phase.
- Make session replacement and prior-session revocation atomic where possible, otherwise explicitly compensating. Revalidate the initiating link/session authority at persistence time. Test failures after grant/session persistence but before old-session revocation or browser-cookie delivery; the current callback spans separate operations.
- Until an approved recovery mechanism exists, reject unlinking the final login character; account deletion is a separate confirmed action. No password/code-only login or recovery is introduced by this plan. If every linked character is inaccessible, explicitly accept/document loss of access or obtain approval for a different recovery policy before P.
- Reauthenticating an existing linked character restores the same application account. Explain that users must first sign into that account before linking a new alt. A signed-out login with an unlinked alt can create a separate application account; matching owner claims must not merge them. Document accidental-duplicate handling: authenticate the accounts separately, explicitly export/delete an unwanted account if chosen, then relink only after its binding is safely released. Do not silently migrate its history.
- Reauthenticating the current primary character restores the same application account; a data-only linked character cannot authenticate while signed out. Users must first sign into the account before linking a new alt. A signed-out login with an unlinked character can create a separate application account; matching owner claims must not merge them. For this pre-production prototype, reset/reseed disposable data instead of implementing an earliest-character migration or compatibility workflow. Production migration and accidental-duplicate handling remain launch gates.
- A new owner of a transferred character must never inherit the old user's history or account. Quarantine/reconcile ownership changes and invalidate affected credentials/sessions; never merge accounts or trust character names as recovery proof. Test that a verified new owner can eventually obtain a separate active binding without the old row permanently blocking signup, and that unrelated old-user characters remain separate.
- Disable local access/cancel jobs first when removing authorization, then attempt remote revocation and purge credentials. If retry requires retaining token material, use a short-lived encrypted revocation-only outbox with no active-sync access and an explicit deletion deadline. Report unavailable EVE revocation as a cleanup obligation, not successful remote revocation.
@@ -313,11 +327,11 @@ Acceptance: per-capability adversarial/evaluation corpora, measured routing/clar
## 15. First implementation batch after this plan
Keep the initial branch focused; do not start every milestone at once.
Keep the initial branch focused; do not start every milestone at once. See the [parallel-worktree task plan](parallel-worktrees.md) for first-batch task briefs, dependencies, file ownership and merge order.
| Work item | Concrete output | Proof of completion |
|---|---|---|
| 1. Baseline/release safety | Isolated test/deploy fixture and backup/migration rehearsal | Existing 97 tests remain green; restore runbook exercised without touching live test identities |
| 1. Local prototype baseline | Local test/check/build feedback and disposable isolated fixtures | Current tests remain green; production deploy/restore/migration evidence waits for prototype approval |
| 2. Dataset, scope and identity contracts | Typed dataset status, scope-upgrade binding, grant revisions and revocation/transfer semantics | Scalar/array grants, reduced scopes, explicit linking, stale-session and job-invalidation tests |
| 3. Durable asset job slice | Queue/worker/lease/refresh fencing for the existing asset endpoint | Kill/restart/duplicate/uncertain-refresh tests preserve good generations |
| 4. Reference/location slice | Runtime search/group/system/station readers + verified station import | Versioned artifact tests; unknown/private locations stay unknown |
@@ -357,4 +371,4 @@ After the foundations, work items 4–6 (inventory) and 7 (wallet balances) can
- Unit/property tests for arithmetic and invariants; isolated integration, native-browser and adversarial tenant tests for changed boundaries.
- Bounded resource use, safe errors, appropriate logs/metrics, retention/deletion behavior and rollback.
- No real-user write smoke against the persistent deployment; consented live validation is recorded separately from mocks.
- The server-side rollout/config gate implemented in M0 remains closed to other users until the slice's acceptance gate is met. It is a planned mechanism, not an existing feature. Public signup remains a separate, explicit operator decision.
- The server-side rollout/config gate implemented in T01 remains closed to other users until the slice's acceptance gate is met. It is an operator-controlled mechanism, not a public-signup decision. Public signup remains separate and explicitly gated.
+2 -2
View File
@@ -7,10 +7,10 @@ These are requirements for the first implementation, not a claim of a production
- Connecting another character is an authenticated, explicit operation. Callback must preserve the initiating user/session binding. Character ownership conflicts fail closed; there is no implicit merge or transfer.
- Refresh tokens are envelope encrypted using a server-held encryption key with authenticated associated data. No raw refresh token or client secret may be a browser property, public Convex query field, URL parameter, log or exception message.
- Browser sessions use high-entropy opaque identifiers, server-side hashes, HttpOnly/SameSite cookies and bounded expiration. Application JWTs use a dedicated issuer/audience/key and short expiry, not EVE bearer tokens. Queries independently enforce the application identity and ownership.
- Server bridge authority is distinct from the deployment administrator credential and browser sessions. All privileged Convex entrypoints require it. Deployment keys are not application runtime defaults.
- Server bridge authority is distinct from the deployment administrator credential and browser sessions. Web/session operations require the general bridge secret; worker lifecycle operations require a separate `CONVEX_SYNC_WORKER_SECRET` and expose no login, session, tenant-administration or enqueue/cancel authority. Deployment keys are not application runtime defaults.
- State-changing browser requests require an exact allowed origin. Normal app pages use `Referrer-Policy: same-origin`: global `no-referrer` would make native POST navigations send `Origin: null` and fail CSRF verification. Auth responses retain `no-referrer` so callback grant URLs never become referrers. Null/missing/foreign origins remain rejected. No arbitrary user-provided fetch URLs, SQL identifiers, executable function names or tool invocations.
- Market/ESI errors are redacted before display. Observability reports route, status, timings and cache/rate-limit signals, never Authorization headers, OAuth codes or secrets.
- Authenticated ESI caches are keyed by character and authorization context. A partial sync cannot replace a complete snapshot. Token refresh is serialized for a character; stale token rotations cannot overwrite newer credentials.
- Authenticated ESI caches are keyed by character and authorization context. A partial sync cannot replace a complete snapshot. Durable jobs use tokenized renewable leases and fenced success/failure publication; duplicate staging is idempotent only for identical payloads. Token refresh is serialized per character with durable CAS; stale token rotations cannot overwrite newer credentials, and uncertain rotation outcomes fail closed as `reconnect_required` rather than replaying.
- Fixture/demo data is explicitly opt-in and visibly marked. Live errors never trigger a silent fallback to demo assets or prices; live reference readers require a production-profile artifact.
- Reference archives are untrusted input. The production importer reads members without extracting arbitrary filesystem paths, rejects unsafe paths and unsupported member types, and enforces compressed/decompressed/member limits, XZ memory limits, archive checksums, and schema validation. Artifact paths/manifests must remain inside the configured reference directory. Cache reuse and fallback verify canonical hashes and the active artifact’s bound provenance; fixture caches cannot satisfy a production import.
- Infisical bootstrap credentials stay in an owner-only local file, separate from application keys. The launcher pins the approved instance and selected project/environment/path, suppresses raw credential-command output, and does not pass bootstrap credentials or the vault token to the application. Application secrets are injected only from an allowlist; deployment admin access requires an explicit operator command. Docker/host administrators can still inspect container environment values.
+135 -23
View File
@@ -1,6 +1,6 @@
# Verification record
Initial milestone verified locally on 2026-09-18; live development rollout checked on 2026-09-19 UTC with Bun 1.3.14. 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
@@ -8,19 +8,143 @@ Initial milestone verified locally on 2026-09-18; live development rollout check
- `bun test`: offline tests exercise domain ownership/freshness, precise integer-cent accumulation, unknown/unpriced/BPC behavior, typed JEV routing and abstention, cryptographic envelopes, PKCE, SSO JWT checks, Convex ownership/session/snapshot rules, ESI cache/pagination/limiter behavior, and reference promotion/rollback/source failure handling. Final run: **66 passed, 0 failed**, across 10 files.
- `bun run build`: production Bun server builds. `svelte-adapter-bun` may emit an unresolved `bun:sqlite` warning while leaving that Bun builtin external. This is not a missing npm dependency: the resulting server and container were executed successfully under Bun.
- `scripts/smoke-web.ts`: executed against both the built production server and the final Docker web container in explicit fixture mode. Assistant, Characters and Assets SSR routes succeeded. `/value` and `/assets` returned the same 208,600,000 integer cents (2,086,000.00 ISK), two characters and two excluded/unpriced stacks. Foreign origins and oversized request bodies were rejected.
- Browser-bundle inspection: no references to `TOKEN_ENCRYPTION_KEY`, `CONVEX_BRIDGE_SECRET`, `APP_AUTH_PRIVATE_KEY`, `refresh_token` or `TYPESAFE_API_KEY` in `build/client`.
- Browser-bundle inspection: no references to `TOKEN_ENCRYPTION_KEY`, `CONVEX_BRIDGE_SECRET`, `CONVEX_SYNC_WORKER_SECRET`, `APP_AUTH_PRIVATE_KEY`, `refresh_token` or `TYPESAFE_API_KEY` in `build/client`.
- `docker compose config --quiet` and `docker build`: passed. The containerized fixture worker also executed with `--network none`; no archives were fetched.
- Real self-hosted Convex: deployed functions/schema/auth to an isolated disposable backend and executed `scripts/smoke-convex.ts`. Verified session lookup, asset publication, preservation after failed staging, cross-user isolation, safe status projection, correctly signed wrong-audience JWT rejection, and session revocation. This caught and fixed an incorrect attempt to read reserved `aud` as a Convex identity custom claim; audience enforcement belongs in the backend `applicationID` auth configuration.
No live EVE credentials, production volumes, or deployed endpoints were used for this T07 checkpoint. Optional disposable Convex smoke procedures remain documented below but are not evidence of a deployment.
The disposable backend/web containers were stopped and test-only environment files and keys removed after verification. Typecheck and all 66 offline tests were rerun successfully without those files.
## Historical verification from prior checkpoints
Tested backend image:
The following evidence was recorded before T07 and is retained as historical context. It is not evidence that this new T07 worker prototype has been deployed or exercised against live ESI.
- The initial disposable Convex smoke deployed functions/schema/auth to an isolated backend and verified session lookup, asset publication, preservation after failed staging, cross-user isolation, safe status projection, correctly signed wrong-audience JWT rejection, and session revocation. It caught and fixed an incorrect attempt to read reserved `aud` as a Convex identity custom claim; audience enforcement belongs in the backend `applicationID` auth configuration.
- The disposable backend/web containers were stopped and test-only environment files and keys removed after that verification. Typecheck and all 66 offline tests were rerun successfully without those files.
- Historical tested backend image:
```text
ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
```
### Historical live development rollout — 2026-09-19 UTC
- The integrated gate recorded **97 tests passed, 0 failed, across 16 files**; Svelte/TypeScript check passed with 0 errors/0 warnings, and the Docker web build passed.
- An operator-created Infisical project was connected through a read-only Universal Auth identity. Generated app encryption/signing/bridge keys were imported, compared against the staging file, and locally verified for encryption round-trip and signing/JWKS consistency; the temporary `.env.keys` was removed and secret values were not displayed.
- Persistent self-hosted Convex schema/functions/auth configuration was deployed. Its administrator credential was derived locally for explicit deployment commands, not stored in the web environment.
- Pangolin/UniFi routing was verified: `https://dev-convex.evebot.app/version` returned HTTP 200 and a real WSS upgrade with `Origin: https://dev.evebot.app` passed. Pangolin authentication remained allowed on the main app but was disabled on the Convex API resource by the operator.
- One real TypeSafe request through `JevIntentRouter` returned `asset_valuation`, model `jev-1.13.0`, confidence 1, in 915 ms; this proves connectivity/routing for that sample, not calibration.
- One real public ESI `/markets/prices` request passed using pinned compatibility `2026-08-18`, returning fresh integer-cent estimates for Tritanium and Rifter. These remain global reference estimates, not Jita bids or executable prices.
- Real Reference Data/Hoboleaks/CCP archives totaling **114,291,615 compressed bytes (~109 MiB)** were parsed. The corrected Docker worker compiled them with `--network none`, a 4 GiB memory limit and two CPUs, explicitly targeting `/app/data/reference` in the shared volume. Peak RSS was not measured. The full online download path has fixture-based receipt/validator tests; the real full rebuild used already downloaded archives rather than fetching another copy.
- The web container opened the deployed production artifact, resolved Tritanium/Rifter, matched manifest/table counts, and passed SQLite integrity/foreign-key checks: **53,001 types, 5,082 blueprints, 8,490 systems, 13,978 gates, 21 explicitly recorded unresolved blueprint references**. Deployed version: `b9afed7830756a5e617fec31a2a88c917f07b8f64d2bc719e2176fb628c1e98b`.
- Live anonymous web smoke passed after deployment: SSR routes, no demo fallback, asset/chat authentication gates, session-token protection, and CSRF rejection. A scan of **13 deployed client artifacts plus anonymous SSR output** found no managed secret values; bootstrap tokens and the deployment-admin key were absent from the web environment.
A subsequent operator test exposed `ERR_INVALID_RESPONSE` when submitting Connect Character: the global `no-referrer` response policy made native browser form submissions send `Origin: null`, which SvelteKit rejected before OAuth initiation. An isolated temporary-profile Firefox test reproduced that behavior and confirmed that `same-origin` preserves the native form Origin. App pages now use `same-origin` while auth responses retain `no-referrer`; null/missing/foreign origins remain rejected. The rebuilt deployment passed header and anonymous endpoint checks. EVE discovery and a server-to-Convex read also passed; diagnostics wrote no OAuth/character records. The operator then reached EVE login but reported a generic callback failure. Redacted callback diagnostics identified `scopes_invalid` after token verification; the parser was updated to accept EVE's singleton scope-string representation as well as arrays, with signed-token login/refresh regressions and malformed-claim rejection. The revised image and anonymous smoke passed. The operator subsequently confirmed successful character authorization and asset synchronization, including refresh-grant verification and owner continuity before asset publication, and confirmed that live Assets and chat `/value` returned matching totals without sharing the private amount.
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).
## 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.
### Historical T07 prototype stage manifest (not the current T08 manifest)
The T07 stage includes the following files. T08 core files are intentionally excluded:
```text
ghcr.io/get-convex/convex-backend@sha256:3aa2ccd6697a75de53948b1ee531d7c9688fe09ef360748d75df46dfc888f69a
.env.example
Dockerfile
README.md
compose.yaml
docs/architecture.md
docs/design-records/t07-durable-sync.md
docs/live-setup.md
docs/parallel-worktrees.md
docs/restore-rehearsal.md
docs/roadmap.md
docs/security.md
docs/verification.md
package.json
scripts/configure-convex.ts
scripts/generate-keys.ts
scripts/lib/infisical.ts
scripts/release-check.ts
scripts/run-sync-worker.ts
scripts/secrets.ts
src/convex/functions.ts
src/convex/schema.ts
src/lib/client/sync-queue.ts
src/lib/domain/model.ts
src/lib/server/application.ts
src/lib/server/store.ts
src/routes/+page.svelte
src/routes/api/sync/+server.ts
src/routes/api/sync/cancel/+server.ts
src/routes/api/sync/status/+server.ts
src/routes/assets/+page.svelte
tests/infisical.test.ts
tests/t07-route-boundaries.test.ts
tests/t07-sync.test.ts
tests/t07-worker-runtime.test.ts
tests/workflow-config.test.ts
workers/sync.ts
```
## Reproducing the real Convex smoke
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:
@@ -30,28 +154,16 @@ SMOKE_ALLOW_WRITE=1 bun scripts/smoke-convex.ts
This writes synthetic users/characters and must not be casually pointed at a production deployment. It needs `CONVEX_BRIDGE_SECRET`, `APP_AUTH_ISSUER` and `APP_AUTH_PRIVATE_KEY` in the calling environment. A wrong-audience test is signed locally; no EVE identity is impersonated and no real EVE account is used.
## Live development rollout — 2026-09-19 UTC
## Deferred deployment and live-provider evidence
- Latest integrated gate (including native-form, callback diagnostics, and singleton-scope compatibility): **97 tests passed, 0 failed, across 16 files**; Svelte/TypeScript check passed with 0 errors/0 warnings. Docker web build passed.
- Connected the operator-created Infisical project through a read-only Universal Auth identity. Generated app encryption/signing/bridge keys were imported by the operator, compared against the staging file, and locally verified for encryption round-trip and signing/JWKS consistency. The temporary `.env.keys` was then removed. Secret values were not displayed.
- Persistent self-hosted Convex schema/functions/auth configuration deployed. Its administrator credential is derived locally for explicit deployment commands, not stored in the web environment.
- Pangolin/UniFi routing was verified: `https://dev-convex.evebot.app/version` returned HTTP 200 and a real WSS upgrade with `Origin: https://dev.evebot.app` passed. Pangolin authentication remains allowed on the main app but was disabled on the Convex API resource by the operator.
- One real TypeSafe request through `JevIntentRouter` returned `asset_valuation`, model `jev-1.13.0`, confidence 1, in 915 ms. This proves connectivity/routing for that sample, not calibration.
- One real public ESI `/markets/prices` request passed using pinned compatibility `2026-08-18`, returning fresh integer-cent estimates for Tritanium and Rifter. These remain global reference estimates, not Jita bids or executable prices.
- Real Reference Data/Hoboleaks/CCP archives totaling **114,291,615 compressed bytes (~109 MiB)** were parsed. The corrected Docker worker compiled them with `--network none`, a 4 GiB memory limit and two CPUs, explicitly targeting `/app/data/reference` in the shared volume. Peak RSS was not measured. The full online download path has fixture-based receipt/validator tests; the real full rebuild used the already downloaded archives rather than fetching another copy.
- The web container's own read-only repository successfully opened the deployed production artifact, resolved Tritanium/Rifter, matched manifest/table counts, and passed SQLite integrity/foreign-key checks: **53,001 types, 5,082 blueprints, 8,490 systems, 13,978 gates, 21 explicitly recorded unresolved blueprint references**. Deployed version: `b9afed7830756a5e617fec31a2a88c917f07b8f64d2bc719e2176fb628c1e98b`.
- Live anonymous web smoke passed after deployment: SSR routes, no demo fallback, asset/chat authentication gates, session-token protection, and CSRF rejection. A scan of **13 deployed client artifacts plus anonymous SSR output** found no managed secret values; bootstrap tokens and the deployment-admin key were absent from the web environment.
A subsequent operator test exposed `ERR_INVALID_RESPONSE` when submitting Connect Character. The global `no-referrer` response policy made native browser form submissions send `Origin: null`; SvelteKit rejected them before OAuth initiation. An isolated, temporary-profile Firefox test reproduced that behavior and confirmed that `same-origin` preserves the native form Origin. App pages now use `same-origin` while auth responses retain `no-referrer`; null/missing/foreign origins remain rejected. The rebuilt deployment passed header and anonymous endpoint checks. EVE discovery and a server-to-Convex read also passed; diagnostics wrote no OAuth/character records. The operator subsequently reached EVE login but reported a generic failure after callback. Redacted callback-stage diagnostics were added and deployed: only fixed failure categories and allowlisted protocol status/claim names are retained; provider descriptions, JWT payloads, credentials, codes, cookies, and user identifiers are excluded. The next redacted callback diagnostic identified `scopes_invalid` after token verification. The parser now accepts EVE's singleton scope-string representation as well as arrays, with signed-token login/refresh regressions and malformed-claim rejection. The revised image and anonymous smoke passed. After that deployment, the operator confirmed successful character authorization and asset synchronization. Explicit sync exercises refresh-grant verification and owner continuity before asset publication. The operator also confirmed that the live Assets page and chat `/value` return matching totals, without sharing the private amount. This completes the initial live sign-in/sync/reference-valuation verification for one character.
Audit regressions cover native-form header/CSRF boundaries, shared artifact-directory precedence, live readers rejecting fixture profiles, offline no-network behavior, archive receipts/replay, ZIP CRC/length/duplicate records, TAR checksums/end markers, duplicate JSON keys, Hoboleaks file metadata, and immutable cache provenance/rollback/reuse. See [live setup](live-setup.md) and [reference operations](reference-operations.md).
No deployed endpoint, live ESI request, live EVE credential, or production rollout is claimed for this T07 checkpoint. Existing operational procedures remain reference material only; deployment, migration/rollback, live-provider, browser failure-injection and production-readiness evidence are deferred. See [live setup](live-setup.md) and [reference operations](reference-operations.md).
## Not claimed as verified
- Live revocation/character-transfer scenarios and additional-character linking; successful sign-in/sync for one real character is now operator-confirmed.
- Live revocation/character-transfer scenarios, additional-character linking, live ESI behavior and deployed-artifact evidence are not claimed for this prototype.
- Broad TypeSafe inference quality or the proposed 0.80 confidence threshold's calibration on EVE requests.
- Compatibility with arbitrary future archive releases, station-data coverage, large-scale memory/performance benchmarks, or production-size market analytics.
- End-to-end browser automation/visual accessibility audit; the web smoke verifies the built server and endpoints, not all browser interactions.
- A comprehensive TLS/proxy security audit, backup/restore drills, rotation/recovery, unlink/revocation UX, retention/GC, or multi-process refresh/job recovery.
- A comprehensive TLS/proxy security audit, backup/restore drills, rotation/recovery, remote revocation and full unlink/delete lifecycle, retention/GC, or multi-process refresh/job recovery. T05 adds local data-only-character cancellation only.
These remain explicit deployment/incremental-development gates, not silent success assumptions.
+280
View File
@@ -0,0 +1,280 @@
import type {
ConsentBinding,
DatasetJob,
DatasetSyncState,
EntityResolution,
InventoryCursor,
InventoryCursorState,
InventoryCursorLookup,
InventoryQuery,
InventoryRow,
InventoryQueryResult,
LocationFact,
ReferenceStationRecord,
ResourceAuthorization,
SignedMoneyNormalizationPolicy,
WalletBalanceResult,
} from '../../src/lib/domain/model';
/** Synthetic, offline-only values used to exercise contract shape and pinning. */
export const syntheticPrincipal = { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' } as const;
export const syntheticResource = {
principal: syntheticPrincipal,
requiredScopes: ['esi-assets.read_assets.v1'],
authorizationRevision: 7,
authorizationEpoch: 1,
} as const;
export const datasetState: DatasetSyncState = {
principal: syntheticPrincipal,
dataset: 'assets',
status: 'ready',
authorizationStatus: 'active',
authorizationEpoch: 1,
grantRevision: 7,
credentialVersion: 3,
coverage: { kind: 'snapshot', generation: 'assets-g7', complete: true },
fetchedAt: 1_700_000_100_000,
upstreamEffectiveAt: null,
};
export const appendDatasetState: DatasetSyncState = {
principal: { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' },
dataset: 'wallet_journal',
status: 'partial',
authorizationStatus: 'active',
authorizationEpoch: 1,
grantRevision: 4,
credentialVersion: 2,
coverage: {
kind: 'append',
checkpoint: 'journal-from-90000000000000001',
sourceIdentity: 'esi:character-wallet-journal',
windowStart: 1_699_900_000_000,
windowEnd: 1_700_000_100_000,
gaps: [{ from: null, to: 'journal-from-90000000000000001', reason: 'older history unavailable upstream' }],
complete: false,
},
fetchedAt: 1_700_000_100_000,
upstreamEffectiveAt: null,
};
export const singletonRows: readonly Pick<InventoryRow, 'isSingleton'>[] = [
{ isSingleton: true },
{ isSingleton: false },
{ isSingleton: null },
];
export const verifiedNamelessStation: ReferenceStationRecord = {
id: 60000001, systemId: 30000142, systemName: 'Jita', regionId: 10000002,
name: null, typeId: 1531, ownerId: 1000125, evidence: 'verified', sourceVerified: true, x: null, y: null, z: null,
};
export const systemListedStationPlaceholder: ReferenceStationRecord = {
...verifiedNamelessStation, id: 60000002, evidence: 'system_listed', sourceVerified: false,
};
export const absentLocation: LocationFact = {
kind: 'unknown', id: '60000003', name: null, parentId: null, sourceVerified: null, referenceVersion: 'reference-g1',
};
export const ambiguousType: EntityResolution<{ id: number; name: string }> = {
status: 'ambiguous',
candidates: [{ id: 34, name: 'Tritanium' }, { id: 35, name: 'Pyerite' }],
};
export const authorization: ResourceAuthorization = {
principal: syntheticPrincipal,
userId: 'fixture-user-a',
status: 'active',
grantRevision: 7,
credentialVersion: 3,
grantedScopes: ['esi-assets.read_assets.v1'],
observedAt: 1_700_000_100_000,
staleAuthority: { state: 'current', observedAt: 1_700_000_100_000, maximumWindowMs: null },
authorizationEpoch: 1,
};
export const consent: ConsentBinding = {
bindingId: 'consent-fixture-1',
sessionId: 'session-fixture-1',
userId: 'fixture-user-a',
intent: 'upgrade_dataset',
capability: 'inventory_query',
resourceRequests: [{
resource: syntheticResource,
requestedScopes: ['esi-assets.read_assets.v1'],
approvedExistingScopes: [],
}],
createdAt: 1_700_000_000_000,
expiresAt: 1_700_000_300_000,
state: 'pending',
};
export const systemJob: DatasetJob = {
id: 'job-reference-fixture-1',
owner: { kind: 'system', service: 'reference' },
authority: 'system',
dataset: 'reference_data',
status: 'queued',
attempts: 0,
dueAt: 1_700_000_200_000,
leaseExpiresAt: null,
publicationFence: {
jobId: 'job-reference-fixture-1',
principal: { kind: 'system', id: 'reference-public' },
dataset: 'reference_data',
resources: [],
coverageKind: 'snapshot',
generation: 'reference-g1',
checkpoint: null,
},
};
export const inventoryCursor: InventoryCursor = { token: 'cursor-fixture-opaque' };
export const inventoryCursorState: InventoryCursorState = {
token: inventoryCursor.token,
status: 'active',
position: { offset: 0, lastItemId: null },
pin: {
callerUserId: 'fixture-user-a',
queryFingerprint: 'sha256:fixture-query',
selectedResources: [syntheticResource],
authorizationRevisions: { 'character:90000000000000001': 7 },
authorizationEpochs: { 'character:90000000000000001': 1 },
snapshotGenerations: { assets: 'assets-g7' },
referenceVersion: 'reference-g1',
priceVersion: 'prices-g1',
expiresAt: 1_700_000_300_000,
},
};
export const inventoryCursorLookup: InventoryCursorLookup = {
token: inventoryCursor.token,
callerUserId: 'fixture-user-a',
queryFingerprint: 'sha256:fixture-query',
selectedResources: [syntheticResource],
authorizationRevisions: { 'character:90000000000000001': 7 },
authorizationEpochs: { 'character:90000000000000001': 1 },
snapshotGenerations: { assets: 'assets-g7' },
referenceVersion: 'reference-g1',
priceVersion: 'prices-g1',
now: 1_700_000_200_000,
};
export const borrowedInventoryCursorLookup: InventoryCursorLookup = { ...inventoryCursorLookup, callerUserId: 'fixture-user-b' };
export const changedQueryInventoryCursorLookup: InventoryCursorLookup = { ...inventoryCursorLookup, queryFingerprint: 'sha256:changed-query' };
export const changedRevisionInventoryCursorLookup: InventoryCursorLookup = {
...inventoryCursorLookup,
authorizationRevisions: { 'character:90000000000000001': 8 },
};
export const inventoryQuery: InventoryQuery = {
callerUserId: 'fixture-user-a',
selectedResources: [syntheticResource],
authorizationRevisions: { 'character:90000000000000001': 7 },
authorizationEpochs: { 'character:90000000000000001': 1 },
filters: { typeIds: [34], minQuantity: '2', blueprintState: 'all' },
sort: { field: 'name', direction: 'ascending', tieBreaker: 'item_id' },
pageSize: 50,
cursor: inventoryCursor,
};
export const inventoryResult: InventoryQueryResult = {
status: 'ready',
rows: [],
summary: { matchingRows: 0, matchingQuantity: '0', estimatedValue: '0', scope: 'all_matching_rows' },
nextCursor: null,
pageState: 'initial',
provenance: {
reportId: 'report-fixture-1',
generatedAt: 1_700_000_200_000,
fetchedAt: { assets: 1_700_000_100_000 },
upstreamEffectiveAt: { assets: null },
input: {
selectedResources: [syntheticResource],
requiredDatasetKinds: ['assets'],
pinSet: 'exact_selected_resource_dataset_set',
datasetPins: [{
selectedResource: syntheticResource,
principal: syntheticPrincipal,
dataset: 'assets',
coverage: { kind: 'snapshot', generation: 'assets-g7', complete: true },
authorizationRevision: 7,
authorizationEpoch: 1,
fetchedAt: 1_700_000_100_000,
upstreamEffectiveAt: null,
}],
authorizationRevisions: { 'character:90000000000000001': 7 },
authorizationEpochs: { 'character:90000000000000001': 1 },
snapshotGenerations: { assets: 'assets-g7' },
appendCheckpoints: {},
referenceVersion: 'reference-g1',
priceVersion: 'prices-g1',
},
sourceSkew: false,
assumptions: [],
},
};
export const afterEndInventoryCursorState: InventoryCursorState = {
...inventoryCursorState,
status: 'exhausted',
position: { offset: 2, lastItemId: '90000000000000042' },
};
export const afterEndInventoryResult: InventoryQueryResult = {
status: 'ready',
rows: [],
summary: { matchingRows: 2, matchingQuantity: '3', estimatedValue: '100', scope: 'all_matching_rows' },
nextCursor: null,
pageState: 'after_end',
provenance: inventoryResult.provenance,
};
export const walletResult: WalletBalanceResult = {
status: 'ready',
components: [{
component: 'wallet_balance',
principal: syntheticPrincipal,
amount: '-125',
fetchedAt: 1_700_000_100_000,
upstreamEffectiveAt: null,
coverage: { kind: 'snapshot', generation: 'wallet-g2', complete: true },
authorizationRevision: 4,
credentialVersion: 2,
}],
provenance: {
...inventoryResult.provenance,
input: {
...inventoryResult.provenance.input,
requiredDatasetKinds: ['wallet_balance'],
pinSet: 'exact_selected_resource_dataset_set',
datasetPins: [{
selectedResource: syntheticResource,
principal: syntheticPrincipal,
dataset: 'wallet_balance',
coverage: { kind: 'snapshot', generation: 'wallet-g2', complete: true },
authorizationRevision: 4,
authorizationEpoch: 1,
fetchedAt: 1_700_000_100_000,
upstreamEffectiveAt: null,
}],
authorizationRevisions: { 'character:90000000000000001': 4 },
authorizationEpochs: { 'character:90000000000000001': 1 },
snapshotGenerations: { wallet_balance: 'wallet-g2' },
},
},
};
/** Synthetic fixture for the approved, fixed signed-decimal policy. */
export const approvedMoneyPolicy: SignedMoneyNormalizationPolicy = {
input: 'decimal_text',
scale: 2,
rounding: 'half_away_from_zero',
grammar: 'json_number_ascii_decimal',
maxRawInputCharacters: 8192,
maxCoefficientDigits: 4096,
exponentRange: [-1000, 1000],
centsShiftRange: [-1000, 1000],
rejectWhitespace: true,
canonicalizeNegativeZero: true,
};
+6 -1
View File
@@ -13,11 +13,16 @@
"reference:fixture": "bun workers/reference.ts --fixture",
"reference:production": "bun workers/reference.ts --production",
"reference:rollback": "bun workers/reference.ts --rollback",
"sync:worker": "bun --no-env-file scripts/run-sync-worker.ts",
"convex:dev": "bunx convex dev",
"convex:configure": "bun scripts/configure-convex.ts",
"keys:generate": "bun scripts/generate-keys.ts",
"secrets:status": "bun scripts/secrets.ts status",
"secrets:run": "bun scripts/secrets.ts run"
"secrets:run": "bun scripts/secrets.ts run",
"release:check": "bun scripts/release-check.ts",
"test:fixture": "bun scripts/fixture-smoke.ts",
"test:release-gates": "bun scripts/release-gate-smoke.ts",
"smoke:restore-marker": "bun scripts/smoke-restore-marker.ts"
},
"dependencies": {
"@sveltejs/kit": "2.70.3",
+6 -3
View File
@@ -1,10 +1,13 @@
export {};
// Run only against your intended self-hosted deployment. Values are never printed.
const names = ['CONVEX_BRIDGE_SECRET', 'APP_AUTH_ISSUER', 'APP_AUTH_JWKS'] as const;
const requiredNames = ['CONVEX_BRIDGE_SECRET', 'CONVEX_SYNC_WORKER_SECRET', 'APP_AUTH_ISSUER', 'APP_AUTH_JWKS', 'APP_CAPABILITY_ALLOWLIST'] as const;
if (!process.env.CONVEX_SELF_HOSTED_URL || !process.env.CONVEX_SELF_HOSTED_ADMIN_KEY) throw new Error('Set self-hosted URL and admin key before configuring Convex.');
for (const name of names) {
if (process.env.CONVEX_SYNC_WORKER_SECRET && process.env.CONVEX_SYNC_WORKER_SECRET === process.env.CONVEX_BRIDGE_SECRET) throw new Error('Worker and general bridge secrets must be distinct.');
for (const name of requiredNames) {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
if (value === undefined || (name !== 'APP_CAPABILITY_ALLOWLIST' && !value)) {
throw new Error(`Missing ${name}`);
}
// Pipe values rather than exposing them in process arguments or shell history.
const child = Bun.spawn(['bun', 'node_modules/convex/bin/main.js', 'env', 'set', name], { stdin: new Blob([value]), stdout: 'pipe', stderr: 'pipe' });
if (await child.exited !== 0) throw new Error(`Could not configure ${name}; check deployment URL/admin key (output suppressed to protect secrets).`);
+92
View File
@@ -0,0 +1,92 @@
import { createServer } from 'node:net';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
async function freePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address ? address.port : 0;
probe.close((error) => error ? reject(error) : resolve(port));
});
});
}
const configuredPort = process.env.SMOKE_WEB_PORT;
const port = configuredPort === undefined ? await freePort() : Number(configuredPort);
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('SMOKE_WEB_PORT must be a valid TCP port');
const base = `http://127.0.0.1:${port}`;
const releaseId = `fixture-${crypto.randomUUID()}`;
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
let server: Bun.Subprocess | undefined;
// Never pass ambient vault/session/provider variables into repository-controlled
// fixture processes. The fixture only needs a minimal, non-secret environment.
const safeEnv = {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: process.env.HOME ?? '/tmp',
TMPDIR: process.env.TMPDIR ?? '/tmp',
};
async function compileFixture(directory: string): Promise<void> {
const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], {
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
env: { ...safeEnv, APP_MODE: 'demo', REFERENCE_DIR: directory },
});
const code = await child.exited;
if (code !== 0) throw new Error('Fixture reference compilation failed');
}
async function waitForServer(expectedReleaseId: string): Promise<void> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(base);
if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return;
} catch { /* startup race */ }
await Bun.sleep(250);
}
throw new Error('Fixture web server did not become ready');
}
try {
await compileFixture(root);
server = Bun.spawn(['bun', 'build/index.js'], {
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
env: {
...safeEnv,
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
APP_RELEASE_ID: releaseId,
APP_CAPABILITY_ALLOWLIST: 'asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions',
},
});
await waitForServer(releaseId);
const rootResponse = await fetch(base);
if (!(await rootResponse.text()).includes('FIXTURE DEMO')) throw new Error('Fixture label missing');
if (rootResponse.headers.get('x-application-release') !== releaseId) throw new Error('Release identifier header missing');
const assets = await fetch(`${base}/assets`);
if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed');
for (const message of ['/value', '/assets']) {
const response = await fetch(`${base}/api/chat`, {
method: 'POST', headers: { origin: base, 'content-type': 'application/json' },
body: JSON.stringify({ message, requestId: crypto.randomUUID() }),
});
if (response.status !== 200) throw new Error(`Fixture assistant failed for ${message}`);
const result = await response.json() as { status?: string; operation?: { status?: string; report?: { total?: string } } };
if (result.status !== 'ready' || result.operation?.status !== 'ready' || result.operation.report?.total !== '208600000') {
throw new Error(`Fixture assistant returned an unexpected result for ${message}`);
}
}
console.log(`Isolated fixture smoke passed: ${base}; synthetic reference/backend only`);
} finally {
if (server) {
server.kill('SIGTERM');
await server.exited;
}
await rm(root, { recursive: true, force: true });
}
+2 -1
View File
@@ -6,8 +6,9 @@ const publicJwk = { ...await exportJWK(publicKey), kid: 'app-v1', alg: 'RS256',
const values = {
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString('base64'),
CONVEX_BRIDGE_SECRET: randomBytes(32).toString('base64url'),
CONVEX_SYNC_WORKER_SECRET: randomBytes(32).toString('base64url'),
APP_AUTH_PRIVATE_KEY: Buffer.from(await exportPKCS8(privateKey)).toString('base64'),
APP_AUTH_JWKS: `data:application/json;base64,${Buffer.from(JSON.stringify({ keys: [publicJwk] })).toString('base64')}`
};
await writeFile('.env.keys', Object.entries(values).map(([k, v]) => `${k}='${v}'`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
console.log('Wrote .env.keys (0600). Import its four values into your Infisical project (dev, /), or a private .env for standalone setup. Never commit it. Existing files are not overwritten.');
console.log('Wrote .env.keys (0600). Import its five values into your Infisical project (dev, /), or a private .env for standalone setup. Never commit it. Existing files are not overwritten.');
+10 -9
View File
@@ -12,9 +12,11 @@ export interface VaultProject {
export const BOOTSTRAP_KEYS = ['INFISICAL_UNIVERSAL_AUTH_CLIENT_ID', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET'] as const;
export const APPLICATION_KEYS = [
'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TYPESAFE_API_KEY', 'TOKEN_ENCRYPTION_KEY',
'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS',
'CONVEX_BRIDGE_SECRET', 'CONVEX_SYNC_WORKER_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS',
] as const;
export const REQUIRED_KEYS = ['EVE_CLIENT_ID', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS'] as const;
export const WORKER_APPLICATION_KEYS = ['EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_SYNC_WORKER_SECRET'] as const;
export const CONFIGURE_APPLICATION_KEYS = ['CONVEX_BRIDGE_SECRET', 'CONVEX_SYNC_WORKER_SECRET', 'APP_AUTH_JWKS'] as const;
export const REQUIRED_KEYS = ['EVE_CLIENT_ID', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'CONVEX_SYNC_WORKER_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS'] as const;
export const ADMIN_KEY = 'CONVEX_SELF_HOSTED_ADMIN_KEY';
export type Environment = Record<string, string | undefined>;
export type CliRunner = (args: string[], env: Environment) => Promise<string>;
@@ -152,12 +154,12 @@ export function cleanEnvironment(base: Environment): Environment {
}
const PUBLIC_KEYS = new Set([
'APP_MODE', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
'APP_MODE', 'APP_RELEASE_ID', 'APP_CAPABILITY_ALLOWLIST', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT',
'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT',
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE',
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE', 'RELEASE_ENV',
]);
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false, target: 'web' | 'worker' | 'configure' = 'web'): Environment {
const env = cleanEnvironment(base);
// Public file is the configured deployment, not defaults inherited from Bun's .env.local.
for (const [key, value] of Object.entries(publicConfig)) {
@@ -165,10 +167,9 @@ export function applicationEnvironment(base: Environment, secrets: Record<string
env[key] = value;
}
// Managed app keys must come from the selected vault, never a stale ambient .env.
for (const key of APPLICATION_KEYS) {
delete env[key];
if (secrets[key] !== undefined) env[key] = secrets[key];
}
for (const key of APPLICATION_KEYS) delete env[key];
const allowedKeys = target === 'worker' ? WORKER_APPLICATION_KEYS : target === 'configure' ? CONFIGURE_APPLICATION_KEYS : APPLICATION_KEYS.filter((key) => key !== 'CONVEX_SYNC_WORKER_SECRET');
for (const key of allowedKeys) if (secrets[key] !== undefined) env[key] = secrets[key];
if (deployment && secrets[ADMIN_KEY]) env[ADMIN_KEY] = secrets[ADMIN_KEY];
return env;
}
+108
View File
@@ -0,0 +1,108 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']);
const DIGEST_IMAGE = /@sha256:[a-f0-9]{64}$/i;
type ReleaseCheckInput = {
mode: string;
releaseId: string;
releaseEnvironment: string;
dashboardImage: string;
convexImage: string;
dockerfile: string;
capabilityAllowlist: string | undefined;
trackedFiles: readonly string[];
browserBundleFiles?: readonly string[];
browserBundlePresent?: boolean;
};
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
const errors: string[] = [];
try { parseCapabilityAllowlist(input.capabilityAllowlist); }
catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); }
if (!RELEASE_ENVIRONMENTS.has(input.releaseEnvironment)) {
errors.push('RELEASE_ENV must be explicitly set to fixture, development, staging, or production');
}
const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging';
if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) {
errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment');
}
if (strictEnvironment && !DIGEST_IMAGE.test(input.dashboardImage)) {
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
}
if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) {
errors.push('CONVEX_IMAGE must be digest-pinned for staging/production');
}
if (strictEnvironment && input.browserBundlePresent === false) {
errors.push('built browser client artifacts are required for staging/production release checks');
}
if (strictEnvironment) {
const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]);
if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) {
errors.push('Dockerfile base images must be digest-pinned for staging/production');
}
}
if (input.mode === 'demo' && strictEnvironment) {
errors.push('APP_MODE=demo is not permitted for a staging/production release');
}
for (const file of input.trackedFiles) {
if (/(^|\/)(?:\.env(?:\.|$)|.*\.(?:pem|key|p12|pfx)$|(?:credentials|secrets)(?:\.(?:json|ya?ml|env|txt)|$)|\.npmrc$)/i.test(file) && file !== '.env.example') {
errors.push(`secret-like file is tracked: ${file}`);
}
}
if ((input.browserBundleFiles ?? []).length > 0) {
errors.push('browser client artifacts contain managed secret names');
}
return errors;
}
function trackedFiles(): string[] {
const result = spawnSync('git', ['ls-files'], { encoding: 'utf8' });
if (result.status !== 0) throw new Error('Unable to inspect tracked files');
return result.stdout.split('\n').filter(Boolean);
}
const MANAGED_SECRET_NAMES = ['TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'CONVEX_SYNC_WORKER_SECRET', 'APP_AUTH_PRIVATE_KEY', 'TYPESAFE_API_KEY', 'refresh_token'];
function browserBundleFiles(directory = 'build/client'): string[] {
try { statSync(directory); } catch { return []; }
const files: string[] = [];
const visit = (path: string) => {
for (const entry of readdirSync(path, { withFileTypes: true })) {
const child = `${path}/${entry.name}`;
if (entry.isDirectory()) visit(child);
else if (entry.isFile()) {
const source = readFileSync(child, 'utf8');
if (MANAGED_SECRET_NAMES.some((name) => source.includes(name))) files.push(child);
}
}
};
visit(directory);
return files;
}
if (import.meta.main) {
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : '');
const errors = releaseSafetyErrors({
mode: process.env.APP_MODE ?? 'live',
releaseId: process.env.APP_RELEASE_ID ?? 'local',
releaseEnvironment,
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
convexImage: process.env.CONVEX_IMAGE ?? '',
dockerfile: readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8'),
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
trackedFiles: trackedFiles(),
browserBundleFiles: browserBundleFiles(),
browserBundlePresent: existsSync('build/client'),
});
if (errors.length) {
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
for (const message of errors) console.error(`- ${message}`);
process.exitCode = 1;
} else {
const capabilities = parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST).join(',') || 'none';
console.log(`Release safety check passed: environment=${releaseEnvironment}, mode=${process.env.APP_MODE ?? 'live'}, capabilities=${capabilities}`);
}
}
+170
View File
@@ -0,0 +1,170 @@
import { createServer } from 'node:net';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const capabilities = 'asset_search,asset_valuation';
const safeEnv = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', TMPDIR: process.env.TMPDIR ?? '/tmp' };
async function freePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address ? address.port : 0;
probe.close((error) => error ? reject(error) : resolve(port));
});
});
}
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const releaseId = `gate-${crypto.randomUUID()}`;
const referenceDir = await mkdtemp(join(tmpdir(), 'eden-gate-reference-'));
let server: Bun.Subprocess | undefined;
let trap: ReturnType<typeof Bun.serve> | undefined;
async function run(args: string[], env: Record<string, string>): Promise<void> {
const child = Bun.spawn(args, { env: { ...safeEnv, ...env }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
if (await child.exited !== 0) throw new Error(`Command failed: ${args.join(' ')}`);
}
async function waitForServer(baseUrl: string, expectedReleaseId: string): Promise<void> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(baseUrl);
if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return;
} catch { /* startup race */ }
await Bun.sleep(250);
}
throw new Error('Rollout-gate smoke server did not become ready');
}
async function assertStatus(response: Response, expected: number, label: string): Promise<void> {
if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}; ${await response.clone().text()}`);
}
try {
await run(['bun', 'workers/reference.ts', '--fixture', '--directory', referenceDir], { APP_MODE: 'demo', REFERENCE_DIR: referenceDir });
server = Bun.spawn(['bun', 'build/index.js'], {
env: {
...safeEnv, APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base, HOST: '127.0.0.1', PORT: String(port),
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: releaseId, APP_CAPABILITY_ALLOWLIST: capabilities,
TYPESAFE_API_KEY: 'sentinel-not-used-when-assistant-is-disabled',
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
});
await waitForServer(base, releaseId);
const headers = { origin: base, 'content-type': 'application/json' };
const chatRequest = () => fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
const chat = await chatRequest();
await assertStatus(chat, 503, 'assistant dispatch');
if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing');
// Chat retains its existing 30-request window for anonymous IPs.
for (let attempt = 2; attempt <= 30; attempt += 1) {
const response = await chatRequest();
await assertStatus(response, 503, `anonymous chat denial ${attempt}`);
await response.text();
}
const chatLimited = await chatRequest();
await assertStatus(chatLimited, 429, 'anonymous chat rate limit');
await chatLimited.text();
const syncRequest = () => fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } });
const sync = await syncRequest();
await assertStatus(sync, 503, 'asset sync');
await sync.text();
// The pre-session anonymous sync bucket allows exactly six denials; the
// seventh request is rate-limited. This is intentionally isolated from the
// authenticated trap subprocess below.
for (let attempt = 2; attempt <= 6; attempt += 1) {
const response = await syncRequest();
await assertStatus(response, 503, `anonymous sync denial ${attempt}`);
await response.text();
}
const syncLimited = await syncRequest();
await assertStatus(syncLimited, 429, 'anonymous sync rate limit');
await syncLimited.text();
const token = await fetch(`${base}/api/session/token`);
await assertStatus(token, 503, 'session token');
const characters = await fetch(`${base}/characters`);
await assertStatus(characters, 200, 'character page');
if (!(await characters.text()).includes('identity')) throw new Error('identity gate reason missing');
const oauth = await fetch(`${base}/auth/eve`, { method: 'POST', headers: { origin: base, 'content-type': 'application/x-www-form-urlencoded' }, redirect: 'manual' });
await assertStatus(oauth, 503, 'OAuth initiation');
const callback = await fetch(`${base}/auth/eve/callback?state=synthetic`, { redirect: 'manual' });
await assertStatus(callback, 303, 'OAuth callback');
if (!callback.headers.get('location')?.includes('auth=disabled')) throw new Error('OAuth callback gate reason missing');
server.kill('SIGTERM');
await server.exited;
server = undefined;
// Live-mode trap: a synthetic session cookie makes any accidental session()
// call observable without contacting Convex or a provider.
let trapCalls = 0;
trap = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => { trapCalls++; return new Response('null', { headers: { 'content-type': 'application/json' } }); } });
const livePort = await freePort();
const liveBase = `http://127.0.0.1:${livePort}`;
const liveRelease = `gate-live-${crypto.randomUUID()}`;
server = Bun.spawn(['bun', 'build/index.js'], {
env: {
...safeEnv, APP_MODE: 'live', APP_ORIGIN: liveBase, ORIGIN: liveBase, HOST: '127.0.0.1', PORT: String(livePort),
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: liveRelease, APP_CAPABILITY_ALLOWLIST: '',
CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`,
CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used',
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
});
await waitForServer(liveBase, liveRelease);
const cookie = { cookie: 'eve_session=synthetic-session' };
const liveRoot = await fetch(liveBase, { headers: cookie });
await assertStatus(liveRoot, 200, 'authenticated fully-disabled root');
const liveChat = await fetch(`${liveBase}/api/chat`, { method: 'POST', headers: { ...headers, origin: liveBase, ...cookie }, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
await assertStatus(liveChat, 503, 'authenticated chat gate');
const liveSync = await fetch(`${liveBase}/api/sync`, { method: 'POST', headers: { origin: liveBase, ...cookie } });
await assertStatus(liveSync, 503, 'authenticated sync gate');
const liveCharacters = await fetch(`${liveBase}/characters`, { headers: cookie });
await assertStatus(liveCharacters, 200, 'authenticated character gate');
const liveAssets = await fetch(`${liveBase}/assets`, { headers: cookie });
await assertStatus(liveAssets, 200, 'authenticated asset gate');
if (trapCalls !== 0) throw new Error(`disabled route performed ${trapCalls} Convex session calls`);
server.kill('SIGTERM');
await server.exited;
server = undefined;
// Enabled live-mode regression: no-cookie requests resolve to anonymous
// users, so the post-session limiter must not reuse the same IP key. Sync
// reaches its 401 response and chat uses invalid JSON schema to avoid every
// provider while still exercising both limiter passes.
const enabledPort = await freePort();
const enabledBase = `http://127.0.0.1:${enabledPort}`;
const enabledRelease = `gate-enabled-${crypto.randomUUID()}`;
server = Bun.spawn(['bun', 'build/index.js'], {
env: {
...safeEnv, APP_MODE: 'live', APP_ORIGIN: enabledBase, ORIGIN: enabledBase, HOST: '127.0.0.1', PORT: String(enabledPort),
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: enabledRelease, APP_CAPABILITY_ALLOWLIST: 'asset_sync,assistant_dispatch',
CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`,
CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used',
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
});
await waitForServer(enabledBase, enabledRelease);
const enabledSyncRequest = () => fetch(`${enabledBase}/api/sync`, { method: 'POST', headers: { origin: enabledBase } });
for (let attempt = 1; attempt <= 6; attempt += 1) {
const response = await enabledSyncRequest();
await assertStatus(response, 401, `enabled anonymous sync denial ${attempt}`);
await response.text();
}
const enabledSyncLimited = await enabledSyncRequest();
await assertStatus(enabledSyncLimited, 429, 'enabled anonymous sync rate limit');
await enabledSyncLimited.text();
const enabledChatRequest = () => fetch(`${enabledBase}/api/chat`, {
method: 'POST', headers: { origin: enabledBase, 'content-type': 'application/json' }, body: '{}',
});
for (let attempt = 1; attempt <= 30; attempt += 1) {
const response = await enabledChatRequest();
await assertStatus(response, 400, `enabled anonymous chat denial ${attempt}`);
await response.text();
}
const enabledChatLimited = await enabledChatRequest();
await assertStatus(enabledChatLimited, 429, 'enabled anonymous chat rate limit');
await enabledChatLimited.text();
if (trapCalls !== 0) throw new Error(`enabled anonymous routes performed ${trapCalls} Convex/provider calls`);
console.log(`Rollout-gate HTTP smoke passed: anonymous thresholds and authenticated disabled paths avoided provider/session calls`);
} finally {
if (server) { server.kill('SIGTERM'); await server.exited; }
trap?.stop();
await rm(referenceDir, { recursive: true, force: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
const forbidden = ['CONVEX_BRIDGE_SECRET', 'CONVEX_SELF_HOSTED_ADMIN_KEY', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS', 'TYPESAFE_API_KEY'];
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', '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<string, string | undefined>): Record<string, string> {
return Object.fromEntries(workerEnvironmentKeys.flatMap((key) => base[key] === undefined ? [] : [[key, base[key] as string]]));
}
export async function runSyncWorkerLauncher(): Promise<void> {
for (const name of forbidden) {
if (process.env[name]) throw new Error(`Sync worker refuses forbidden runtime credential: ${name}`);
}
if (!process.env.CONVEX_SYNC_WORKER_SECRET) throw new Error('CONVEX_SYNC_WORKER_SECRET is required for the sync worker');
const bundled = join(process.cwd(), 'build', 'sync-worker.js');
let entry = bundled;
let temporary = false;
if (!existsSync(entry)) {
entry = join('/tmp', `eve-sync-worker-${process.pid}.js`);
const result = await Bun.build({ entrypoints: ['workers/sync.ts'], target: 'bun', outdir: '/tmp', naming: `eve-sync-worker-${process.pid}.js`, minify: false });
if (!result.success) throw new Error('Could not bundle the sync worker');
temporary = true;
}
const child = Bun.spawn(['bun', entry], { env: workerEnvironment(process.env), stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
try { process.exitCode = await child.exited; }
finally {
process.off('SIGINT', interrupt); process.off('SIGTERM', terminate);
if (temporary) await rm(entry, { force: true });
}
}
if (import.meta.main) await runSyncWorkerLauncher();
+11 -4
View File
@@ -10,7 +10,7 @@ const HELP = `Infisical-backed development deployment (secret values are never p
bun run secrets:run -- <trusted command> [arguments...]
bun run secrets:run --deployment -- <trusted deployment command> [arguments...]
Optional: --credentials-file PATH (default ~/.config/evebot/infisical.env)
Optional: --credentials-file PATH (default ~/.config/evebot/infisical.env), --worker to inject only worker-safe application secrets, or --deployment --configure for Convex function configuration.
Project metadata: .infisical.json. Public deployment configuration: config/dev.env.
Only allowlisted app secrets are injected. --deployment additionally permits the Convex admin key
and, when absent from the vault, derives it from the local persistent backend without printing it.
@@ -23,22 +23,29 @@ export function parseArguments(args: string[]) {
let command: string[] = [];
let credentialsFile = join(homedir(), '.config/evebot/infisical.env');
let deployment = false;
let worker = false;
let configure = false;
for (let i = 0; i < rest.length; i++) {
if (rest[i] === '--') { command = rest.slice(i + 1); break; }
if (rest[i] === '--credentials-file' && rest[i + 1] && !rest[i + 1].startsWith('--')) credentialsFile = resolve(rest[++i]);
else if (rest[i] === '--deployment') deployment = true;
else if (rest[i] === '--worker') worker = true;
else if (rest[i] === '--configure') configure = true;
// `bun run` consumes its own -- separator before forwarding script arguments.
else if (action === 'run' && !rest[i].startsWith('-')) { command = rest.slice(i); break; }
else throw new Error('Unknown or incomplete secrets helper option');
}
if (action === 'run' && !command.length) throw new Error('Supply a trusted command after --');
if (action === 'status' && command.length) throw new Error('status does not execute a command');
return { action, command, credentialsFile, deployment };
if (worker && deployment) throw new Error('Worker secret mode cannot request deployment admin credentials');
if (worker && configure) throw new Error('Worker and configure secret modes are mutually exclusive');
if (configure && !deployment) throw new Error('--configure requires --deployment so the Convex admin credential is available');
return { action, command, credentialsFile, deployment, worker, configure };
}
async function main(args: string[]): Promise<void> {
if (!args.length || args[0] === '--help') { console.log(HELP); return; }
const { action, command, credentialsFile, deployment } = parseArguments(args);
const { action, command, credentialsFile, deployment, worker, configure } = parseArguments(args);
const project = validateProject(JSON.parse(await readFile(new URL('../.infisical.json', import.meta.url), 'utf8')));
const credentials = await readCredentials(credentialsFile);
const secrets = await fetchSecrets(project, credentials);
@@ -54,7 +61,7 @@ async function main(args: string[]): Promise<void> {
return;
}
const publicConfig = parseEnv(await readFile(new URL('../config/dev.env', import.meta.url), 'utf8'));
const env = applicationEnvironment(process.env, secrets, publicConfig, deployment);
const env = applicationEnvironment(process.env, secrets, publicConfig, deployment, worker ? 'worker' : configure ? 'configure' : 'web');
if (deployment && !env[ADMIN_KEY]) env[ADMIN_KEY] = await localDeploymentAdmin(env);
const child = Bun.spawn(command, { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
const interrupt = () => child.kill('SIGINT');
+1 -1
View File
@@ -10,7 +10,7 @@ const url = process.env.CONVEX_SELF_HOSTED_URL!;
const store = new ConvexStore(url, process.env.CONVEX_BRIDGE_SECRET!);
const now = Date.now(); const hash = hashSecret(randomSecret());
const characterId = 90_000_000 + Math.floor(Math.random() * 1_000_000);
const user = await store.completeLogin({ characterId, name: 'Disposable Test Pilot', ownerHash: hashSecret(randomSecret()), scopes: ['esi-assets.read_assets.v1'], encryptedTokens: 'test-only-encrypted-placeholder', tokenExpiresAt: now + 60_000, sessionHash: hash, sessionExpiresAt: now + 60_000 });
const user = await store.completeLogin({ characterId, name: 'Disposable Test Pilot', ownerHash: hashSecret(randomSecret()), scopes: ['esi-assets.read_assets.v1'], intent: 'initial_login', capability: 'asset_search', requestedScopes: ['esi-assets.read_assets.v1'], approvedExistingScopes: [], encryptedTokens: 'test-only-encrypted-placeholder', tokenExpiresAt: now + 60_000, sessionHash: hash, sessionExpiresAt: now + 60_000 });
assert.equal((await store.session(hash))?.userId, user.userId);
const generation = await store.beginSync(user.userId, characterId);
await store.stageAssets(user.userId, characterId, generation, 0, [{ itemId: '9223372036854775806', characterId, typeId: 34, quantity: 50, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }]);
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { ConvexStore } from '../src/lib/server/store';
import { hashSecret } from '../src/lib/server/security';
import {
parseRestoreMarkerPhase,
validateRestoreMarkerTarget,
SYNTHETIC_CHARACTER_ID_MIN,
validateRestoreMarkerWrite,
} from '../src/lib/server/restore-marker';
const marker = process.env.RESTORE_MARKER;
const phase = parseRestoreMarkerPhase(process.env.RESTORE_MARKER_PHASE);
const target = validateRestoreMarkerTarget(process.env.CONVEX_SELF_HOSTED_URL);
validateRestoreMarkerWrite({
phase,
allowWrite: process.env.SMOKE_ALLOW_WRITE,
confirmation: process.env.RESTORE_MARKER_CONFIRMATION,
});
const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? String(SYNTHETIC_CHARACTER_ID_MIN + 1));
const expiryRaw = process.env.RESTORE_MARKER_SESSION_EXPIRES_AT;
const sessionExpiresAt = expiryRaw === undefined ? Date.now() + 7 * 24 * 60 * 60_000 : Number(expiryRaw);
if (!marker || !/^[A-Za-z0-9._-]{8,100}$/.test(marker)) throw new Error('RESTORE_MARKER must be a synthetic 8–100 character identifier');
if (!Number.isSafeInteger(characterId) || characterId < SYNTHETIC_CHARACTER_ID_MIN) throw new Error(`RESTORE_MARKER_CHARACTER_ID must be a reserved synthetic ID >= ${SYNTHETIC_CHARACTER_ID_MIN}`);
if (!Number.isSafeInteger(sessionExpiresAt) || sessionExpiresAt <= Date.now()) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT must be a future millisecond timestamp');
if (phase !== 'write' && expiryRaw === undefined) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT is required when verifying or invalidating a marker');
const store = new ConvexStore(target.toString(), process.env.CONVEX_BRIDGE_SECRET!);
const sessionHash = hashSecret(`restore-marker-session:${marker}`);
const ownerHash = hashSecret(`restore-marker-owner:${marker}`);
if (phase === 'write') {
const now = Date.now();
const user = await store.completeLogin({
characterId,
name: `Restore marker ${marker}`,
ownerHash,
scopes: ['esi-assets.read_assets.v1'],
intent: 'initial_login',
capability: 'asset_search',
requestedScopes: ['esi-assets.read_assets.v1'],
approvedExistingScopes: [],
encryptedTokens: `restore-marker-placeholder:${marker}`,
tokenExpiresAt: sessionExpiresAt,
sessionHash,
sessionExpiresAt,
});
console.log(`Restore marker written: character=${characterId}, user=${user.userId}, expiresAt=${sessionExpiresAt}`);
} else if (phase === 'verify') {
const session = await store.session(sessionHash);
assert(session, 'restore marker session is missing');
const characters = await store.characters(session.userId);
assert(characters.some((character) => character.id === characterId && character.name === `Restore marker ${marker}`), 'restore marker character is missing or changed');
console.log(`Restore marker verified: character=${characterId}, user=${session.userId}, expiresAt=${sessionExpiresAt}`);
} else if (phase === 'expect-invalidated') {
assert.equal(await store.session(sessionHash), null, 'restore marker session remains usable; invalidation is incomplete');
console.log(`Restore marker invalidation verified: character=${characterId}, beforeExpiresAt=${sessionExpiresAt}`);
} else {
throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated');
}
+1386 -47
View File
File diff suppressed because it is too large Load Diff
+141 -1
View File
@@ -4,11 +4,52 @@ import { v } from "convex/values";
/** The persisted character state is deliberately a small, explicit state machine. */
export const characterStateValidator = v.union(
v.literal("never"),
v.literal("queued"),
v.literal("syncing"),
v.literal("ready"),
v.literal("error"),
);
export const characterLoginRoleValidator = v.union(v.literal("primary"), v.literal("data_only"));
export const authorizationStatusValidator = v.union(
v.literal("active"),
v.literal("revocation_pending"),
v.literal("quarantined"),
v.literal("revoked"),
v.literal("transferred"),
v.literal("reconnect_required"),
);
export const datasetStateValidator = v.union(
v.literal("never"),
v.literal("queued"),
v.literal("syncing"),
v.literal("ready"),
v.literal("partial"),
v.literal("stale"),
v.literal("blocked"),
v.literal("error"),
v.literal("quarantined"),
);
export const datasetStatusValidator = v.object({
dataset: v.string(),
status: datasetStateValidator,
grantRevision: v.number(),
authorizationEpoch: v.number(),
credentialVersion: v.number(),
generation: v.optional(v.string()),
syncedAt: v.optional(v.number()),
invalidatedAt: v.optional(v.number()),
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(),
@@ -17,6 +58,8 @@ export const assetValidator = v.object({
locationId: v.string(),
locationType: v.string(),
locationFlag: v.string(),
// Legacy rows may omit this source fact; absent means unknown, never inferred.
isSingleton: v.optional(v.boolean()),
isBlueprintCopy: v.optional(v.boolean()),
});
@@ -28,6 +71,12 @@ const characterFields = {
ownerHash: v.string(),
scopes: v.array(v.string()),
state: characterStateValidator,
loginRole: characterLoginRoleValidator,
authorizationStatus: authorizationStatusValidator,
grantRevision: v.number(),
credentialVersion: v.number(),
authorizationEpoch: v.number(),
datasetStatuses: v.array(datasetStatusValidator),
syncedAt: v.optional(v.number()),
expiresAt: v.optional(v.number()),
error: v.optional(v.string()),
@@ -65,6 +114,13 @@ export default defineSchema({
encryptedVerifier: v.string(),
expiresAt: v.number(),
returnTo: v.string(),
// OAuth intent is bound server-side, not reconstructed from callback input.
intent: v.string(),
capability: v.string(),
requestedScopes: v.array(v.string()),
approvedExistingScopes: v.array(v.string()),
initiatingSessionHash: v.optional(v.string()),
targetCharacterId: v.optional(v.number()),
consumedAt: v.optional(v.number()),
})
.index("by_state_binding", ["stateHash", "bindingHash"])
@@ -88,10 +144,23 @@ 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()),
error: v.optional(v.string()),
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"]),
@@ -104,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(),
@@ -130,9 +222,57 @@ export default defineSchema({
createdAt: v.number(),
updatedAt: v.number(),
leaseExpiresAt: v.optional(v.number()),
characterId: v.optional(v.number()),
// Durable sync orchestration fields. They are optional for pre-T07
// placeholder rows; new jobs always populate the complete fence.
dataset: v.optional(v.string()),
dedupeKey: v.optional(v.string()),
dueAt: v.optional(v.number()),
attempts: v.optional(v.number()),
maxAttempts: v.optional(v.number()),
priority: v.optional(v.number()),
lastClaimedAt: v.optional(v.number()),
leaseToken: v.optional(v.string()),
workerId: v.optional(v.string()),
cancelRequested: v.optional(v.boolean()),
cancelledAt: v.optional(v.number()),
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
generation: v.optional(v.string()),
grantRevision: v.optional(v.number()),
authorizationEpoch: v.optional(v.number()),
credentialVersion: v.optional(v.number()),
scopes: v.optional(v.array(v.string())),
ownerKind: v.optional(v.string()),
authority: v.optional(v.string()),
cooldownGroup: v.optional(v.string()),
})
.index("by_user_status", ["userId", "status"])
.index("by_lease_expires_at", ["leaseExpiresAt"]),
.index("by_lease_expires_at", ["leaseExpiresAt"])
.index("by_dedupe", ["dedupeKey"])
.index("by_status_due", ["status", "dueAt"])
.index("by_kind_status_due", ["kind", "status", "dueAt"])
.index("by_asset_sync_queue", ["kind", "dataset", "authority", "ownerKind", "status", "dueAt"])
.index("by_user_asset_status", ["userId", "kind", "dataset", "authority", "ownerKind", "status"]),
refreshLeases: defineTable({
userId: v.string(),
characterId: v.number(),
leaseToken: v.string(),
ownerId: v.string(),
expiresAt: v.number(),
expectedVersion: v.number(),
dispatchStartedAt: v.optional(v.number()),
}).index("by_user_character", ["userId", "characterId"]),
syncCooldowns: defineTable({
context: v.string(),
until: v.number(),
group: v.optional(v.string()),
kind: v.string(),
updatedAt: v.number(),
}).index("by_context", ["context"]).index("by_until", ["until"]),
referenceSyncMetadata: defineTable({
source: v.string(),
+27 -25
View File
@@ -1,6 +1,7 @@
import { queryGeneric } from "convex/server";
import { v } from "convex/values";
import { characterStateValidator } from "./schema";
import { characterStateValidator, authorizationStatusValidator, characterLoginRoleValidator, datasetStatusValidator } from "./schema";
import { capabilityEnabled, parseBackendCapabilityAllowlist } from "../lib/server/release-gate";
const safeAssetSyncValidator = v.object({
status: characterStateValidator,
@@ -13,54 +14,55 @@ const safeCharacterValidator = v.object({
userId: v.string(),
name: v.string(),
scopes: v.array(v.string()),
loginRole: characterLoginRoleValidator,
authorizationStatus: authorizationStatusValidator,
grantRevision: v.number(),
credentialVersion: v.number(),
authorizationEpoch: v.number(),
datasets: v.array(datasetStatusValidator),
assets: safeAssetSyncValidator,
});
function isUsableSession(session: any, timestamp = Date.now()): boolean {
return Boolean(
session &&
!session.revokedAt &&
session.expiresAt > timestamp,
);
return Boolean(session && !session.revokedAt && session.expiresAt > timestamp);
}
/**
* Browser-facing character status. Convex validates RS256 issuer/audience
* through auth.config.ts; the checks here make the application boundary and
* session revocation explicit as well. No owner hashes, tokens, generations,
* or raw synchronization errors cross this projection.
*/
function deploymentCapabilityEnabled(capability: 'subscriptions'): boolean {
try {
return capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
} catch {
return false;
}
}
/** Browser-facing status: no credentials, owner evidence, generations or raw errors. */
export const characters = queryGeneric({
args: {},
returns: v.union(v.null(), v.array(safeCharacterValidator)),
handler: async (ctx) => {
if (!deploymentCapabilityEnabled('subscriptions')) return null;
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const expectedIssuer = process.env.APP_AUTH_ISSUER;
if (!expectedIssuer || identity.issuer !== expectedIssuer) return null;
// Convex's customJwt provider enforces applicationID/audience before this
// query runs. `aud` is a reserved claim and is not exposed on UserIdentity;
// only the application issuer and session binding are checked here.
const customIdentity = identity as typeof identity & { sid?: unknown };
const sessionId = typeof customIdentity.sid === "string" ? customIdentity.sid : null;
if (!sessionId) return null;
const session = await ctx.db
.query("sessions")
.withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId))
.first();
const session = await ctx.db.query("sessions").withIndex("by_session_id", (q: any) => q.eq("sessionId", sessionId)).first();
if (!isUsableSession(session) || session.userId !== identity.subject) return null;
const rows = await ctx.db
.query("characters")
.withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject))
.collect();
const rows = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", identity.subject)).collect();
return rows.map((character: any) => ({
id: character.characterId,
userId: identity.subject,
name: character.name,
scopes: character.scopes,
loginRole: character.loginRole,
authorizationStatus: character.authorizationStatus,
grantRevision: character.grantRevision,
credentialVersion: character.credentialVersion,
authorizationEpoch: character.authorizationEpoch,
datasets: character.datasetStatuses,
assets: {
status: character.state,
...(character.syncedAt === undefined ? {} : { syncedAt: character.syncedAt }),
+7
View File
@@ -1,8 +1,15 @@
import type { Handle } from '@sveltejs/kit';
import { configuration } from './lib/server/config';
// Validate deployment settings while the server module initializes, rather
// than waiting for an arbitrary request to discover a typo in the allowlist.
const startupConfig = configuration();
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('cache-control', 'no-store');
response.headers.set('x-content-type-options', 'nosniff');
// The identifier is immutable build metadata, never a secret or an auth
// decision. It makes rollback/operator diagnostics auditable.
response.headers.set('x-application-release', startupConfig.releaseId);
// Native POST navigations under no-referrer send Origin: null, breaking
// strict CSRF checks. Ordinary pages preserve same-origin form Origin while
// suppressing cross-origin referrers; auth URLs (including callback codes)
+95
View File
@@ -0,0 +1,95 @@
export type SyncJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined';
export type SyncJob = {
id: string;
dataset: 'assets';
characterId: number;
status: SyncJobStatus;
attempts: number;
dueAt: number;
createdAt: number;
updatedAt: number;
leaseExpiresAt: number | null;
cancelRequested: boolean;
error?: string;
};
export type SyncSkip = { characterId: number; reason: 'authorization_required' | 'queue_full' | 'enqueue_failed' };
export type SyncEnqueueResponse = { queued: number; jobs: SyncJob[]; skipped: SyncSkip[]; partial?: boolean; demo?: boolean };
const JOB_STATUSES = new Set<SyncJobStatus>(['queued', 'running', 'succeeded', 'failed', 'cancelled', 'quarantined']);
const SKIP_REASONS = new Set<SyncSkip['reason']>(['authorization_required', 'queue_full', 'enqueue_failed']);
function parseJob(value: unknown): SyncJob | null {
if (!value || typeof value !== 'object') return null;
const row = value as Record<string, unknown>;
if (typeof row.id !== 'string' || typeof row.characterId !== 'number' || !Number.isSafeInteger(row.characterId) || typeof row.status !== 'string' || !JOB_STATUSES.has(row.status as SyncJobStatus)) return null;
return {
id: row.id, dataset: 'assets', characterId: row.characterId, status: row.status as SyncJobStatus,
attempts: typeof row.attempts === 'number' ? row.attempts : 0,
dueAt: typeof row.dueAt === 'number' ? row.dueAt : 0,
createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0,
updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
leaseExpiresAt: typeof row.leaseExpiresAt === 'number' ? row.leaseExpiresAt : null,
cancelRequested: row.cancelRequested === true,
...(typeof row.error === 'string' ? { error: row.error } : {}),
};
}
export function parseSyncEnqueueBody(value: unknown): SyncEnqueueResponse | { error: string } {
if (!value || typeof value !== 'object') return { error: 'Synchronization response was invalid.' };
const body = value as Record<string, unknown>;
if (!Array.isArray(body.jobs) || !Array.isArray(body.skipped)) return { error: typeof body.error === 'string' ? body.error : 'Synchronization response was invalid.' };
const jobs = body.jobs.map(parseJob);
const skipped = body.skipped.map((entry: unknown) => {
if (!entry || typeof entry !== 'object') return null;
const row = entry as Record<string, unknown>;
return typeof row.characterId === 'number' && Number.isSafeInteger(row.characterId) && typeof row.reason === 'string' && SKIP_REASONS.has(row.reason as SyncSkip['reason'])
? { characterId: row.characterId, reason: row.reason as SyncSkip['reason'] } : null;
});
if (jobs.some((job) => job === null) || skipped.some((entry) => entry === null)) return { error: 'Synchronization response was invalid.' };
const queued = typeof body.queued === 'number' && Number.isSafeInteger(body.queued) && body.queued >= 0 ? body.queued : jobs.length;
return { queued, jobs: jobs as SyncJob[], skipped: skipped as SyncSkip[], ...(body.partial === true ? { partial: true } : {}), ...(body.demo === true ? { demo: true } : {}) };
}
export async function enqueueSync(signal?: AbortSignal): Promise<{ response: Response; body: SyncEnqueueResponse | { error: string } }> {
const response = await fetch('/api/sync', signal ? { method: 'POST', signal } : { method: 'POST' });
return { response, body: parseSyncEnqueueBody(await response.json().catch(() => null)) };
}
export const SYNC_STATUS_TIMEOUT_MS = 15_000;
export async function syncJobStatus(id: string, signal?: AbortSignal, timeoutMs = SYNC_STATUS_TIMEOUT_MS): Promise<SyncJob | null> {
const requestController = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => { requestController.abort(); reject(new DOMException('The operation was aborted', 'AbortError')); };
if (signal?.aborted) onAbort();
else signal?.addEventListener('abort', onAbort, { once: true });
});
const timedOut = new Promise<never>((_, reject) => {
timeout = setTimeout(() => { requestController.abort(); reject(new Error('Synchronization status timed out.')); }, timeoutMs);
});
try {
const response = await Promise.race([fetch(`/api/sync/status?job=${encodeURIComponent(id)}`, { signal: requestController.signal }), timedOut, aborted]);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Synchronization status unavailable.');
return await response.json() as SyncJob;
} finally {
if (timeout !== undefined) clearTimeout(timeout);
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
}
}
export async function cancelSyncJob(id: string): Promise<SyncJob | null> {
const response = await fetch('/api/sync/cancel', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jobId: id }),
});
if (!response.ok) throw new Error('Could not cancel synchronization.');
return await response.json() as SyncJob;
}
export function syncTerminal(status: SyncJobStatus): boolean {
return status === 'succeeded' || status === 'failed' || status === 'cancelled' || status === 'quarantined';
}
+3 -5
View File
@@ -1,20 +1,18 @@
<script lang="ts">
import type { AssetOperation } from '$lib/domain/model';
import { formatIskAmount, formatQuantity } from '$lib/domain/formatting';
let { result, onSync }: { result: AssetOperation; onSync?: () => void } = $props();
const isk = (cents: string) => {
const n = BigInt(cents); return `${(n / 100n).toLocaleString('en-US')}.${(n % 100n).toString().padStart(2, '0')}`;
};
const time = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
</script>
{#if result.status === 'ready'}
<section class="panel" aria-label="Reference valuation">
<div class="row spread"><div><small>{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK</small><div class="stat">{isk(result.report.total)}</div></div><div class="muted">{result.report.items.length} asset stacks · {result.report.characters.length} characters<br />{result.report.unpricedItems} unpriced stacks excluded</div></div>
<div class="row spread"><div><small>{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK</small><div class="stat">{formatIskAmount(result.report.total)}</div></div><div class="muted">{result.report.items.length} asset stacks · {result.report.characters.length} characters<br />{result.report.unpricedItems} unpriced stacks excluded</div></div>
<p class="muted">Not immediate liquidation, a Jita quote, or a replacement-cost estimate. No order depth, fees, taxes or hauling costs are included. Unit prices are rounded to 0.01 ISK before multiplication.</p>
<small>Prices: {result.report.prices.source}<br />Fetched/revalidated: {time(result.report.prices.asOf)} (not a source price timestamp)<br />Reference artifact: {result.report.referenceVersion}</small>
{#each result.report.warnings as warning}<p class="warning">{warning}</p>{/each}
</section>
<div class="table-wrap"><table><thead><tr><th>Item</th><th>Character</th><th>Location ID (unresolved)</th><th class="number">Quantity</th><th class="number">Estimated ISK</th></tr></thead><tbody>
{#each result.report.items as item (`${item.characterId}:${item.itemId}`)}<tr><td>{item.name}{#if item.exclusion}<br /><small>{item.exclusion.replaceAll('_', ' ')}</small>{/if}</td><td>{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}</td><td>{item.locationId}</td><td class="number">{item.quantity.toLocaleString('en-US')}</td><td class="number">{item.estimatedValue === null ? 'Unpriced' : isk(item.estimatedValue)}</td></tr>{/each}
{#each result.report.items as item (`${item.characterId}:${item.itemId}`)}<tr><td>{item.name}{#if item.exclusion}<br /><small>{item.exclusion.replaceAll('_', ' ')}</small>{/if}</td><td>{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}</td><td>{item.locationId}</td><td class="number">{formatQuantity(item.quantity)}</td><td class="number">{item.estimatedValue === null ? 'Unpriced' : formatIskAmount(item.estimatedValue)}</td></tr>{/each}
</tbody></table></div>
{#if result.report.items.length === 0}<p>No assets in the synchronized snapshots.</p>{/if}
{:else if result.status === 'unavailable'}
+1
View File
@@ -95,6 +95,7 @@ function isFiniteSafeTypeId(value: unknown): value is number {
function validateAsset(asset: Asset, characterId: number): string | null {
if (!asset || typeof asset.itemId !== 'string' || !/^[1-9]\d*$/.test(asset.itemId)) return 'Asset item ID is invalid';
if (asset.characterId !== characterId) return 'Asset ownership validation failed';
if (asset.isSingleton !== undefined && typeof asset.isSingleton !== 'boolean') return 'Asset singleton flag is invalid';
if (!isFiniteSafeTypeId(asset.typeId)) return 'Asset type ID is invalid';
try {
validateQuantity(asset.quantity);
+453
View File
@@ -2,8 +2,21 @@ import type {
Action,
Capability,
CapabilityGate,
ConsentBinding,
ConsentCallbackResult,
CompleteReportInputPin,
ConsentResourceRequest,
DatasetJob,
DatasetKind,
DatasetSyncState,
InventoryCursorLookup,
InventoryCursorState,
InventoryQueryResult,
MissingRequirement,
Requirement,
ResourceAuthorization,
ResourcePrincipal,
SelectedResource,
UserId,
} from './model';
@@ -12,12 +25,337 @@ export const CAPABILITY_REQUIREMENTS: Readonly<Record<Capability, readonly Requi
asset_search: ['assets'],
asset_valuation: ['assets', 'market_data'],
manufacturing_opportunities: ['assets', 'blueprints', 'skills', 'market_data'],
inventory_query: ['assets', 'reference_data'],
/** Explicit opt-in for character-scoped private structure evidence. */
inventory_locations: ['assets', 'reference_data'],
wallet_balance: ['wallet_balance'],
};
/** Assets scope verified against the 2026-08-18 ESI OpenAPI contract. */
export const ESI_ASSETS_SCOPE = 'esi-assets.read_assets.v1';
export const ESI_STRUCTURES_SCOPE = 'esi-universe.read_structures.v1';
export const ESI_WALLET_SCOPE = 'esi-wallet.read_character_wallet.v1';
export const ESI_BLUEPRINTS_SCOPE = 'esi-characters.read_blueprints.v1';
export const ESI_SKILLS_SCOPE = 'esi-skills.read_skills.v1';
export const ESI_SKILL_QUEUE_SCOPE = 'esi-skills.read_skillqueue.v1';
export const ASSETS_SCOPE = ESI_ASSETS_SCOPE;
export const REQUIRED_ASSETS_SCOPE = ESI_ASSETS_SCOPE;
export const WALLET_BALANCE_SCOPE = ESI_WALLET_SCOPE;
export type CapabilityDefinition = {
capability: Capability;
datasets: readonly DatasetKind[];
requiredScopes: readonly string[];
supportsResourceSelection: boolean;
allowedPrincipalKinds: readonly ResourcePrincipal['kind'][];
};
/**
* The allowlisted capability surface is declarative. Consumers may use this to
* construct consent requests, but it does not itself authorize a principal.
*/
export const CAPABILITY_DEFINITIONS: Readonly<Record<Capability, CapabilityDefinition>> = {
asset_search: {
capability: 'asset_search', datasets: ['assets'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
asset_valuation: {
capability: 'asset_valuation', datasets: ['assets', 'market_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
manufacturing_opportunities: {
capability: 'manufacturing_opportunities',
datasets: ['assets', 'blueprints', 'skills', 'market_data'],
requiredScopes: [ESI_ASSETS_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE],
supportsResourceSelection: true,
allowedPrincipalKinds: ['character'],
},
inventory_query: {
capability: 'inventory_query', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
inventory_locations: {
capability: 'inventory_locations', datasets: ['assets', 'reference_data'], requiredScopes: [ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
wallet_balance: {
capability: 'wallet_balance', datasets: ['wallet_balance'], requiredScopes: [ESI_WALLET_SCOPE], supportsResourceSelection: true, allowedPrincipalKinds: ['character'],
},
};
export const SERVER_SCOPE_ALLOWLIST: readonly string[] = [
ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE, ESI_WALLET_SCOPE, ESI_BLUEPRINTS_SCOPE, ESI_SKILLS_SCOPE, ESI_SKILL_QUEUE_SCOPE,
];
const SYSTEM_JOB_DATASETS: Readonly<Record<'reference' | 'public_feed' | 'maintenance', readonly DatasetKind[]>> = {
reference: ['reference_data'],
public_feed: ['market_data'],
maintenance: ['assets', 'blueprints', 'skills', 'market_data', 'reference_data', 'wallet_balance', 'wallet_journal', 'wallet_transactions'],
};
export type InventoryCursorValidation =
| { valid: true }
| { valid: false; reason: 'token_mismatch' | 'expired' | 'exhausted' | 'caller_mismatch' | 'query_mismatch' | 'resource_mismatch' | 'authorization_mismatch' | 'input_version_mismatch' | 'malformed_state' };
export type InventoryPageValidation =
| { valid: true }
| { valid: false; reason: 'invalid_page_state' | 'terminal_cursor' | 'empty_page_summary' };
export type CompleteReportPinValidation =
| { valid: true }
| { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'duplicate_resource' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' };
export type DatasetJobFenceValidation =
| { valid: true }
| { valid: false; reason: 'job_mismatch' | 'empty_user_fence' | 'system_resource_fence' | 'owner_mismatch' };
export type ConsentCallbackValidation =
| { valid: true }
| { valid: false; reason: 'binding_mismatch' | 'resource_mismatch' | 'scope_mismatch' | 'revision_mismatch' | 'duplicate_resource' };
export type ResourceCapabilityScopeValidation = CapabilityScopeValidation & {
principal: ResourcePrincipal;
};
export type CapabilityScopeValidation = {
valid: boolean;
requiredScopes: readonly string[];
allowlistedScopes: readonly string[];
preservedScopes: readonly string[];
missingScopes: readonly string[];
unexpectedScopes: readonly string[];
duplicateScopes: readonly string[];
};
/**
* Validate a consent request against required capability scopes plus an
* explicitly server-approved set of existing grants. Callers cannot turn an
* arbitrary requested scope into a preserved grant.
*/
export function validateCapabilityScopes(
capability: Capability,
requestedScopes: readonly string[],
approvedExistingScopes: readonly string[] = [],
): CapabilityScopeValidation {
return validateScopeLists(CAPABILITY_DEFINITIONS[capability].requiredScopes, requestedScopes, approvedExistingScopes);
}
function validateScopeLists(
requiredScopes: readonly string[],
requestedScopes: readonly string[],
approvedExistingScopes: readonly string[],
): CapabilityScopeValidation {
const serverAllowlist = new Set(SERVER_SCOPE_ALLOWLIST);
const duplicateScopes = [...new Set([...findDuplicates(requestedScopes), ...findDuplicates(approvedExistingScopes)])];
const preservedScopes = approvedExistingScopes.filter((scope) => serverAllowlist.has(scope));
const allowlistedScopes = [...new Set([...requiredScopes, ...preservedScopes])];
const allowlisted = new Set(allowlistedScopes);
const missingScopes = requiredScopes.filter((scope) => !requestedScopes.includes(scope));
const unexpectedScopes = requestedScopes.filter((scope) => !allowlisted.has(scope) || !serverAllowlist.has(scope));
return {
valid: missingScopes.length === 0 && unexpectedScopes.length === 0 && duplicateScopes.length === 0,
requiredScopes: [...requiredScopes],
allowlistedScopes,
preservedScopes: preservedScopes.filter((scope) => requestedScopes.includes(scope)),
missingScopes,
unexpectedScopes,
duplicateScopes,
};
}
/** Keyed wrapper used by consent flows; the approved scopes belong to this resource. */
export function validateResourceCapabilityScopes(
capability: Capability,
request: ConsentResourceRequest,
): ResourceCapabilityScopeValidation {
const requiredScopes = [...new Set([...CAPABILITY_DEFINITIONS[capability].requiredScopes, ...request.resource.requiredScopes])];
const validated = validateScopeLists(requiredScopes, request.requestedScopes, request.approvedExistingScopes);
return { principal: request.resource.principal, ...validated };
}
/** Validate callback correspondence before any credential or grant is persisted. */
export function validateConsentCallback(binding: ConsentBinding, callback: ConsentCallbackResult, now = Date.now()): ConsentCallbackValidation {
if (callback.bindingId !== binding.bindingId) return { valid: false, reason: 'binding_mismatch' };
if (callback.status === 'recovery_required' || callback.status === 'denied') return { valid: true };
if (binding.state !== 'pending' || !Number.isFinite(now) || !Number.isFinite(binding.createdAt) || !Number.isFinite(binding.expiresAt)
|| binding.expiresAt <= binding.createdAt || now < binding.createdAt || now >= binding.expiresAt) return { valid: false, reason: 'binding_mismatch' };
if (callback.resourceGrants.length !== binding.resourceRequests.length) return { valid: false, reason: 'resource_mismatch' };
if (hasDuplicatePrincipals(binding.resourceRequests.map((request) => request.resource.principal))) return { valid: false, reason: 'duplicate_resource' };
const seen = new Set<string>();
for (const grant of callback.resourceGrants) {
const key = principalFingerprint(grant.request.resource.principal);
if (seen.has(key)) return { valid: false, reason: 'duplicate_resource' };
seen.add(key);
const bound = binding.resourceRequests.find((request) => samePrincipal(request.resource.principal, grant.request.resource.principal));
if (!bound || !sameResourceRequest(bound, grant.request)) return { valid: false, reason: 'resource_mismatch' };
const scopes = validateResourceCapabilityScopes(binding.capability, { ...grant.request, requestedScopes: grant.grantedScopes });
if (!scopes.valid) return { valid: false, reason: 'scope_mismatch' };
if (!validCounter(bound.resource.authorizationEpoch) || !validCounter(bound.resource.authorizationRevision)
|| !validCounter(grant.authorizationEpoch) || !validCounter(grant.grantRevision) || !validCounter(grant.credentialVersion)
|| grant.authorizationEpoch !== bound.resource.authorizationEpoch || grant.grantRevision < bound.resource.authorizationRevision) {
return { valid: false, reason: 'revision_mismatch' };
}
}
return seen.size === binding.resourceRequests.length ? { valid: true } : { valid: false, reason: 'resource_mismatch' };
}
function sameResourceRequest(left: ConsentResourceRequest, right: ConsentResourceRequest): boolean {
return samePrincipal(left.resource.principal, right.resource.principal)
&& left.resource.authorizationRevision === right.resource.authorizationRevision
&& left.resource.authorizationEpoch === right.resource.authorizationEpoch
&& sameStrings(left.resource.requiredScopes, right.resource.requiredScopes)
&& sameStrings(left.requestedScopes, right.requestedScopes)
&& sameStrings(left.approvedExistingScopes, right.approvedExistingScopes);
}
function validCounter(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
function samePrincipal(left: ResourcePrincipal, right: ResourcePrincipal): boolean {
if (left.kind !== right.kind || left.id !== right.id) return false;
if (left.kind === 'character' || left.kind === 'corporation') {
return right.kind === left.kind && right.ownerUserId === left.ownerUserId;
}
return true;
}
function principalFingerprint(principal: ResourcePrincipal): string {
switch (principal.kind) {
case 'user': return `user\u0000${principal.id}`;
case 'character': return `character\u0000${principal.id}\u0000${principal.ownerUserId}`;
case 'corporation': return `corporation\u0000${principal.id}\u0000${principal.ownerUserId}`;
case 'system': return `system\u0000${principal.id}`;
}
}
function findDuplicates(values: readonly string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) duplicates.add(value);
seen.add(value);
}
return [...duplicates];
}
function hasDuplicatePrincipals(principals: readonly ResourcePrincipal[]): boolean {
return findDuplicates(principals.map(principalFingerprint)).length > 0;
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) return false;
const sortedLeft = [...left].sort();
const sortedRight = [...right].sort();
return sortedLeft.every((value, index) => value === sortedRight[index]);
}
/** Validate every server-resolved value before consuming an opaque cursor. */
export function validateInventoryCursorUse(state: InventoryCursorState, lookup: InventoryCursorLookup): InventoryCursorValidation {
if (typeof state.token !== 'string' || state.token.length === 0 || state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' };
if (state.status !== 'active' && state.status !== 'exhausted') return { valid: false, reason: 'malformed_state' };
if (!validCursorPosition(state.position)) return { valid: false, reason: 'malformed_state' };
if (state.status === 'exhausted') return { valid: false, reason: 'exhausted' };
if (!Number.isFinite(lookup.now) || !Number.isFinite(state.pin.expiresAt) || lookup.now >= state.pin.expiresAt) return { valid: false, reason: 'expired' };
if (state.pin.callerUserId !== lookup.callerUserId) return { valid: false, reason: 'caller_mismatch' };
if (state.pin.queryFingerprint !== lookup.queryFingerprint) return { valid: false, reason: 'query_mismatch' };
if (hasDuplicatePrincipals(state.pin.selectedResources.map((resource) => resource.principal))
|| hasDuplicatePrincipals(lookup.selectedResources.map((resource) => resource.principal))) return { valid: false, reason: 'resource_mismatch' };
if (!sameSelectedResourceList(state.pin.selectedResources, lookup.selectedResources)) return { valid: false, reason: 'resource_mismatch' };
if (!sameRecord(state.pin.authorizationRevisions, lookup.authorizationRevisions)
|| !sameRecord(state.pin.authorizationEpochs, lookup.authorizationEpochs)) return { valid: false, reason: 'authorization_mismatch' };
if (!sameRecord(state.pin.snapshotGenerations, lookup.snapshotGenerations)
|| state.pin.referenceVersion !== lookup.referenceVersion || state.pin.priceVersion !== lookup.priceVersion) return { valid: false, reason: 'input_version_mismatch' };
return { valid: true };
}
/** Enforce the page discriminants without implementing page retrieval. */
export function validateInventoryPageResult(result: InventoryQueryResult): InventoryPageValidation {
if (result.status !== 'ready') {
return ['partial', 'stale', 'authorization_required', 'unavailable'].includes(result.status)
? { valid: true }
: { valid: false, reason: 'invalid_page_state' };
}
if (!['initial', 'continuation', 'after_end'].includes(result.pageState)) return { valid: false, reason: 'invalid_page_state' };
if (result.pageState === 'after_end') {
return result.rows.length === 0 && result.nextCursor === null
? { valid: true }
: { valid: false, reason: 'terminal_cursor' };
}
if (result.pageState === 'initial' && result.rows.length === 0
&& (result.summary.matchingRows !== 0 || result.summary.matchingQuantity !== '0' || (result.summary.estimatedValue !== null && result.summary.estimatedValue !== '0'))) {
return { valid: false, reason: 'empty_page_summary' };
}
if (result.pageState === 'continuation' && result.rows.length === 0) return { valid: false, reason: 'invalid_page_state' };
return { valid: true };
}
function validCursorPosition(position: unknown): boolean {
if (!position || typeof position !== 'object') return false;
const candidate = position as { offset?: unknown; lastItemId?: unknown };
if (!Number.isSafeInteger(candidate.offset) || (candidate.offset as number) < 0) return false;
if (candidate.offset === 0) return candidate.lastItemId === null;
return typeof candidate.lastItemId === 'string' && /^(0|[1-9][0-9]*)$(?![\s\S])/.test(candidate.lastItemId);
}
function sameSelectedResourceList(left: readonly SelectedResource[], right: readonly SelectedResource[]): boolean {
return left.length === right.length && left.every((resource) => right.some((candidate) => sameSelectedResource([resource], candidate)));
}
function sameRecord<T extends string | number | null>(left: Readonly<Record<string, T>>, right: Readonly<Record<string, T>>): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length && leftKeys.every((key) => right[key] === left[key]);
}
/** Validate the exact selected-resource × required-dataset pin set. */
export function validateCompleteReportPins(input: CompleteReportInputPin): CompleteReportPinValidation {
if (input.pinSet !== 'exact_selected_resource_dataset_set') return { valid: false, reason: 'unexpected_pin' };
if (hasDuplicatePrincipals(input.selectedResources.map((resource) => resource.principal))
|| findDuplicates(input.requiredDatasetKinds).length > 0) return { valid: false, reason: 'duplicate_resource' };
const expected = new Set(input.selectedResources.flatMap((resource) => input.requiredDatasetKinds.map((dataset) => `${principalFingerprint(resource.principal)}|${dataset}`)));
const seen = new Set<string>();
for (const pin of input.datasetPins) {
const key = `${principalFingerprint(pin.selectedResource.principal)}|${pin.dataset}`;
if (!samePrincipal(pin.principal, pin.selectedResource.principal)) return { valid: false, reason: 'principal_mismatch' };
if (!sameSelectedResource(input.selectedResources, pin.selectedResource)
|| pin.authorizationRevision !== pin.selectedResource.authorizationRevision
|| pin.authorizationEpoch !== pin.selectedResource.authorizationEpoch) return { valid: false, reason: 'revision_mismatch' };
if (!expected.has(key)) return { valid: false, reason: 'unexpected_pin' };
if (seen.has(key)) return { valid: false, reason: 'duplicate_pin' };
seen.add(key);
if (!pin.coverage.complete) return { valid: false, reason: 'incomplete_coverage' };
}
return seen.size === expected.size ? { valid: true } : { valid: false, reason: 'missing_pin' };
}
function sameSelectedResource(resources: readonly SelectedResource[], candidate: SelectedResource): boolean {
return resources.some((resource) => samePrincipal(resource.principal, candidate.principal)
&& resource.authorizationRevision === candidate.authorizationRevision
&& resource.authorizationEpoch === candidate.authorizationEpoch
&& sameStrings(resource.requiredScopes, candidate.requiredScopes));
}
/** Validate duplicated job/fence identity before a worker can publish. */
export function validateDatasetJobFence(job: DatasetJob): DatasetJobFenceValidation {
const fence = job.publicationFence;
if (fence.jobId !== job.id || fence.dataset !== job.dataset) return { valid: false, reason: 'job_mismatch' };
if (job.authority === 'system') {
const service = 'service' in job.owner ? job.owner.service : null;
const allowedDatasets = service === null ? [] : SYSTEM_JOB_DATASETS[service];
return fence.resources.length === 0 && fence.principal.kind === 'system' && allowedDatasets.includes(job.dataset)
? { valid: true }
: { valid: false, reason: 'system_resource_fence' };
}
if (fence.resources.length === 0 || fence.principal.kind === 'system') return { valid: false, reason: 'empty_user_fence' };
const owner = job.owner as { kind: 'user'; userId: UserId } | { kind: 'corporation'; principal: ResourcePrincipal };
if (owner.kind === 'user') {
return principalBelongsToUser(fence.principal, owner.userId)
&& fence.resources.every((resource) => principalBelongsToUser(resource.principal, owner.userId))
? { valid: true }
: { valid: false, reason: 'owner_mismatch' };
}
if (owner.kind === 'corporation') {
return samePrincipal(fence.principal, owner.principal) && fence.resources.every((resource) => samePrincipal(resource.principal, owner.principal))
? { valid: true }
: { valid: false, reason: 'owner_mismatch' };
}
return { valid: false, reason: 'owner_mismatch' };
}
export const CONNECT_ACTION: Action = {
kind: 'connect_character',
@@ -58,6 +396,8 @@ export function requirementsMissing(
/**
* Gate a capability using only deterministic application state. In particular,
* this function never delegates an authorization decision to an AI model.
* This compatibility helper accepts precomputed missing requirements; callers
* at an authorization boundary should use gateCapabilityWithAuthorization.
*/
export function gateCapability(
capability: Capability,
@@ -70,6 +410,119 @@ export function gateCapability(
return { status: 'available' };
}
export type CapabilityAuthorizationContext = {
userId: UserId | null;
/** Server-resolved grant records, one per selected resource; never global user scopes. */
resourceAuthorizations: readonly ResourceAuthorization[];
selectedResources: readonly SelectedResource[];
datasets: readonly DatasetSyncState[];
};
const REQUIREMENT_DATASET: Partial<Record<Requirement, DatasetKind>> = {
assets: 'assets',
wallet_balance: 'wallet_balance',
market_data: 'market_data',
reference_data: 'reference_data',
blueprints: 'blueprints',
skills: 'skills',
};
const SCOPE_REQUIREMENT: Readonly<Record<string, Requirement>> = {
[ESI_ASSETS_SCOPE]: 'assets',
[ESI_WALLET_SCOPE]: 'wallet_balance',
[ESI_BLUEPRINTS_SCOPE]: 'blueprints',
[ESI_SKILLS_SCOPE]: 'skills',
[ESI_SKILL_QUEUE_SCOPE]: 'skills',
};
function principalBelongsToUser(principal: ResourcePrincipal, userId: UserId): boolean {
if (principal.kind === 'user') return principal.id === userId;
if (principal.kind === 'character' || principal.kind === 'corporation') return principal.ownerUserId === userId;
return false;
}
function addMissing(missing: MissingRequirement[], entry: MissingRequirement): void {
if (!missing.some((current) => current.requirement === entry.requirement && current.reason === entry.reason && current.characterId === entry.characterId)) {
missing.push(entry);
}
}
/**
* Authoritative contract gate: derives missing scopes/resources/dataset state
* instead of treating a non-empty user ID as authorization.
*/
export function gateCapabilityWithAuthorization(
capability: Capability,
context: CapabilityAuthorizationContext,
actions: readonly Action[] = [],
): CapabilityGate {
if (typeof context.userId !== 'string' || context.userId.length === 0) return authenticationRequired(capability);
const userId = context.userId;
const definition = CAPABILITY_DEFINITIONS[capability];
const missing: MissingRequirement[] = [];
const selectedKeys = new Set<string>();
for (const resource of context.selectedResources) {
selectedKeys.add(principalFingerprint(resource.principal));
const authorization = context.resourceAuthorizations.find((entry) => samePrincipal(entry.principal, resource.principal));
if (!authorization || authorization.userId !== userId || authorization.status !== 'active') {
addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' });
continue;
}
if (authorization.grantRevision !== resource.authorizationRevision || authorization.authorizationEpoch !== resource.authorizationEpoch) {
addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'stale' });
}
if (!definition.allowedPrincipalKinds.includes(resource.principal.kind)) {
addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' });
continue;
}
if (!principalBelongsToUser(resource.principal, userId)) {
addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' });
}
for (const scope of [...definition.requiredScopes, ...resource.requiredScopes]) {
if (!authorization.grantedScopes.includes(scope)) {
addMissing(missing, { requirement: SCOPE_REQUIREMENT[scope] ?? CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' });
}
}
}
if (definition.supportsResourceSelection && context.selectedResources.length === 0 && definition.requiredScopes.length > 0) {
addMissing(missing, { requirement: CAPABILITY_REQUIREMENTS[capability][0]!, reason: 'authorization' });
}
for (const requirement of CAPABILITY_REQUIREMENTS[capability]) {
const dataset = REQUIREMENT_DATASET[requirement];
if (!dataset) continue;
const allStates = context.datasets.filter((state) => state.dataset === dataset);
// Public reference/market state may be system-owned; private datasets must
// match one of the already-validated selected principals exactly.
const isPublicDataset = dataset === 'reference_data' || dataset === 'market_data';
const states = isPublicDataset
? allStates.filter((state) => state.authorizationStatus === 'active' && state.principal.kind === 'system')
: allStates.filter((state) => {
const selected = context.selectedResources.find((resource) => samePrincipal(resource.principal, state.principal));
return selected !== undefined
&& state.authorizationStatus === 'active'
&& state.grantRevision === selected.authorizationRevision
&& state.authorizationEpoch === selected.authorizationEpoch
&& principalBelongsToUser(state.principal, userId);
});
const selectedStateKeys = new Set(states.map((state) => principalFingerprint(state.principal)));
const missingSelectedState = !isPublicDataset && [...selectedKeys].some((key) => !selectedStateKeys.has(key));
if (states.length === 0 || missingSelectedState) {
addMissing(missing, { requirement, reason: 'synchronization' });
continue;
}
if (states.some((state) => state.status === 'stale')) {
addMissing(missing, { requirement, reason: 'stale' });
} else if (states.some((state) => state.status !== 'ready')) {
addMissing(missing, { requirement, reason: 'synchronization' });
}
}
if (missing.length > 0) return requirementsMissing(capability, missing, actions);
return { status: 'available' };
}
/**
* Manufacturing is intentionally a visible, typed not-yet-implemented gate.
* Skills and blueprints are separate requirements so a caller cannot mistake
+32
View File
@@ -0,0 +1,32 @@
import { parseIsk, validateQuantity } from './money';
/** Group a non-negative decimal integer without converting it to Number. */
function groupInteger(value: bigint): string {
const digits = value.toString(10);
let grouped = '';
for (let index = 0; index < digits.length; index += 1) {
if (index > 0 && (digits.length - index) % 3 === 0) grouped += ',';
grouped += digits[index];
}
return grouped;
}
/**
* Format serialized integer hundredths of an ISK for user-facing output.
*
* Amounts are parsed as bigint and negative values are formatted from their
* absolute magnitude, so -1 cent is rendered as -0.01 rather than -0.-1.
*/
export function formatIskAmount(value: string | bigint): string {
const cents = typeof value === 'bigint' ? value : parseIsk(value);
const negative = cents < 0n;
const absolute = negative ? -cents : cents;
const whole = absolute / 100n;
const fraction = (absolute % 100n).toString(10).padStart(2, '0');
return `${negative ? '-' : ''}${groupInteger(whole)}.${fraction}`;
}
/** Format an inventory quantity after applying the domain's quantity policy. */
export function formatQuantity(value: number | bigint): string {
return groupInteger(validateQuantity(value));
}
+909
View File
@@ -0,0 +1,909 @@
import type { Asset, DecimalId, ReferenceVersion } from './model';
export type { DecimalId } from './model';
/** The signed-int64 spelling used by ESI for character-scoped item IDs. */
export const MAX_DECIMAL_ID = 9_223_372_036_854_775_807n;
export const DEFAULT_CONTAINMENT_DEPTH = 64;
export const MAX_CONTAINMENT_ITEMS = 50_000;
/** Matches the reviewed T06 helper request bound. */
export const MAX_ENRICHMENT_IDS = 1_000;
/** Location rows include breadcrumbs/reference facts; keep persistence chunks smaller than asset chunks. */
export const MAX_LOCATION_ENRICHMENT_CHUNK = 100;
export const MAX_ENRICHMENT_NAME_LENGTH = 512;
export type InventoryLocationAuthority = {
userId: string;
principal: string;
characterId: number;
scopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
};
export type EnrichmentObservationState = 'observed' | 'not_requested' | 'forbidden' | 'unavailable';
export type EnrichmentNameStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
export type EnrichmentLocationStatus = 'resolved' | 'missing' | 'expired' | 'forbidden' | 'unavailable' | 'not_requested';
export type NamesObservation = {
requestedIds: readonly DecimalId[];
names: readonly { itemId: DecimalId; name: string }[];
missing: readonly DecimalId[];
retrievedAt: number;
expiresAt: number;
complete: boolean;
/** Set by an adapter when the request itself was denied or could not run. */
state?: EnrichmentObservationState;
characterId?: number;
};
export type LocationsObservation = {
requestedIds: readonly DecimalId[];
locations: readonly { itemId: DecimalId; position: { x: number; y: number; z: number } }[];
missing: readonly DecimalId[];
retrievedAt: number;
expiresAt: number;
complete: boolean;
state?: EnrichmentObservationState;
characterId?: number;
};
/** Each ESI helper call remains capped at MAX_ENRICHMENT_IDS; a generation may pin bounded batches. */
export type NamesObservationBatch = NamesObservation;
export type LocationsObservationBatch = LocationsObservation;
export type NameUnavailableReason = 'name_missing' | 'name_expired' | 'name_forbidden' | 'name_unavailable';
export type LocationUnavailableReason = 'location_missing' | 'location_expired' | 'location_forbidden' | 'location_unavailable';
export type ContainmentUnresolvedReason =
| 'invalid_item_id'
| 'invalid_location_id'
| 'duplicate_item_id'
| 'missing_parent'
| 'parent_unresolved'
| 'cycle'
| 'depth_cap'
| 'invalid_asset'
| 'cap_exceeded';
export type ReferenceUnresolvedReason =
| 'reference_unknown'
| 'reference_unavailable'
| 'unsafe_reference_id'
| 'forbidden_structure'
| 'unavailable_structure';
export type UnresolvedReason = ContainmentUnresolvedReason | NameUnavailableReason | LocationUnavailableReason | ReferenceUnresolvedReason;
export type FittedClassification = 'fitted' | 'not_fitted' | 'unknown';
export type FittedClassificationDetail = {
classification: FittedClassification;
/** The source flag is retained even when the classification is intentionally unknown. */
rawFlag: string;
reason: 'slot_flag' | 'ordinary_location_flag' | 'ambiguous_autofit' | 'ambiguous_special_bay';
};
/**
* Flags with an unambiguous fitting meaning. AutoFit and all specialized bays
* deliberately remain unknown: ESI does not say whether they contain a fit.
*/
const FITTED_SLOT_FLAG = /^(?:Hi|Med|Lo|Rig|SubSystem)Slot[0-7]$/;
const AMBIGUOUS_FIT_FLAG = new Set([
'AutoFit', 'BoosterBay', 'Cargo', 'DroneBay', 'FighterBay', 'FighterTube0', 'FighterTube1', 'FighterTube2', 'FighterTube3', 'FighterTube4',
'ExpeditionHold', 'FleetHangar', 'FrigateEscapeBay', 'Hangar', 'HangarAll', 'MoonMaterialBay', 'QuafeBay', 'ShipHangar', 'SubSystemBay',
'SpecializedAmmoHold', 'SpecializedAsteroidHold', 'SpecializedCommandCenterHold', 'SpecializedFuelBay', 'SpecializedGasHold',
'SpecializedIceHold', 'SpecializedIndustrialShipHold', 'SpecializedLargeShipHold', 'SpecializedMaterialBay', 'SpecializedMediumShipHold',
'SpecializedMineralHold', 'SpecializedOreHold', 'SpecializedPlanetaryCommoditiesHold', 'SpecializedSalvageHold', 'SpecializedShipHold',
'SpecializedSmallShipHold', 'StructureDeedBay', 'Wardrobe',
]);
export function classifyFittedFlag(rawFlag: unknown): FittedClassificationDetail {
const flag = typeof rawFlag === 'string' ? rawFlag : '';
if (FITTED_SLOT_FLAG.test(flag)) return { classification: 'fitted', rawFlag: flag, reason: 'slot_flag' };
if (flag === 'AutoFit') return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_autofit' };
if (AMBIGUOUS_FIT_FLAG.has(flag)) return { classification: 'unknown', rawFlag: flag, reason: 'ambiguous_special_bay' };
return { classification: 'not_fitted', rawFlag: flag, reason: 'ordinary_location_flag' };
}
export type ContainmentRoot =
| { kind: 'external'; id: DecimalId; locationType: string; locationFlag: string }
| { kind: 'item'; id: DecimalId; locationType: 'item'; locationFlag: string }
| { kind: 'unresolved'; id: DecimalId | null; locationType: string; locationFlag: string };
export type Breadcrumb = {
itemId: DecimalId;
typeId: number;
/** Null is intentional; no synthetic "Item <id>" names are generated. */
name: string | null;
};
export type ContainmentNode = {
itemId: DecimalId;
characterId: number;
typeId: number;
quantity: number;
locationId: DecimalId;
locationType: string;
/** Exact source value, including AutoFit/special-bay values. */
locationFlag: string;
isSingleton?: boolean;
isBlueprintCopy?: boolean;
parentItemId: DecimalId | null;
root: ContainmentRoot;
depth: number | null;
breadcrumbs: readonly Breadcrumb[];
fitted: FittedClassificationDetail;
/** Compatibility projections: ambiguous flags are null/unknown, never false by guess. */
fittedClassification: FittedClassification;
isFitted: boolean | null;
unresolved: readonly ContainmentUnresolvedReason[];
};
export type ContainmentInput = {
assets: readonly Asset[];
characterId?: number;
names?: ReadonlyMap<DecimalId, string> | Readonly<Record<string, string>>;
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
maxDepth?: number;
};
export type ContainmentResult = {
nodes: readonly ContainmentNode[];
/** Root IDs are sorted decimal strings and have no fabricated labels. */
roots: readonly ContainmentRoot[];
unresolved: readonly { itemId: DecimalId | null; reasons: readonly ContainmentUnresolvedReason[] }[];
complete: boolean;
maxDepth: number;
};
export type ReferenceEnrichmentBatchPin = {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
};
export type ReferenceLocationFactPin = {
kind: 'station' | 'system' | 'region';
id: DecimalId;
name: string | null;
parentId: DecimalId | null;
evidence: 'verified' | 'system_listed';
sourceVerified: boolean;
systemId: DecimalId | null;
regionId: DecimalId | null;
referenceVersion: ReferenceVersion;
};
export type StructureObservationPin = {
structureId: DecimalId;
characterId: number;
status: 'accessible' | 'forbidden' | 'unavailable';
evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable';
name: string | null;
solarSystemId: DecimalId | null;
ownerId: DecimalId | null;
typeId: DecimalId | null;
observedAt: number;
expiresAt: number;
referenceVersion: ReferenceVersion;
};
export type ReferenceEnrichmentPin = {
version: 1;
assetGeneration: string;
authority: InventoryLocationAuthority;
referenceVersion: ReferenceVersion;
containmentDepth: number;
names: {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
batches?: readonly ReferenceEnrichmentBatchPin[];
};
locations: {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
resolvedIds: readonly DecimalId[];
missingIds: readonly DecimalId[];
retrievedAt: number | null;
expiresAt: number | null;
contentDigest: string;
batches?: readonly ReferenceEnrichmentBatchPin[];
};
structures?: readonly StructureObservationPin[];
/** Digest is an internal consistency check; pinned referenceVersion supplies provenance. */
referenceFacts?: readonly ReferenceLocationFactPin[];
referenceFactsDigest?: string;
structureRevision: string;
};
export type LocationUnresolved = {
itemId: DecimalId | null;
reasons: readonly UnresolvedReason[];
};
export type LocationReference = {
kind: 'station' | 'system' | 'region' | 'structure' | 'unknown';
id: DecimalId;
name: string | null;
parentId: DecimalId | null;
/** `verified` and `system_listed` are intentionally not collapsed. */
evidence: 'verified' | 'system_listed' | 'private_structure_access' | 'unknown';
sourceVerified: boolean | null;
referenceVersion: ReferenceVersion;
/** Private structure access does not establish docking permission. */
dockingPermission: 'unknown';
/** Present for structure observations; public catalog references are null. */
observedAt?: number | null;
expiresAt?: number | null;
system: LocationReference | null;
region: LocationReference | null;
};
export type EnrichedLocationNode = Omit<ContainmentNode, 'unresolved'> & {
/** Persisted rows carry the complete merged reason set, not only graph reasons. */
unresolved: readonly UnresolvedReason[];
customName: string | null;
nameStatus: EnrichmentNameStatus;
position: { x: number; y: number; z: number } | null;
locationStatus: EnrichmentLocationStatus;
location: LocationReference;
};
export type LocationEnrichmentResult = {
status: 'complete' | 'partial';
complete: boolean;
items: readonly EnrichedLocationNode[];
/** Aliases make the seam convenient for sync and later projection code. */
rows: readonly EnrichedLocationNode[];
roots: readonly LocationReference[];
unresolved: readonly LocationUnresolved[];
inputPin: ReferenceEnrichmentPin;
enrichmentVersion: string;
};
export type PersistedLocationValidationOptions = {
authority?: InventoryLocationAuthority;
};
/** Runtime guard used at the Convex worker boundary before private rows are persisted. */
export function validatePersistedLocationRows(value: unknown, options?: PersistedLocationValidationOptions): asserts value is readonly EnrichedLocationNode[] {
if (!Array.isArray(value) || value.length > MAX_LOCATION_ENRICHMENT_CHUNK) throw new TypeError('location enrichment chunk is oversized');
const allowed = new Set(['itemId', 'characterId', 'typeId', 'quantity', 'locationId', 'locationType', 'locationFlag', 'isSingleton', 'isBlueprintCopy', 'parentItemId', 'root', 'depth', 'breadcrumbs', 'fitted', 'fittedClassification', 'isFitted', 'unresolved', 'customName', 'nameStatus', 'position', 'locationStatus', 'location']);
const statuses = new Set(['resolved', 'missing', 'expired', 'forbidden', 'unavailable', 'not_requested']);
const reasons = new Set(['invalid_item_id', 'invalid_location_id', 'duplicate_item_id', 'missing_parent', 'parent_unresolved', 'cycle', 'depth_cap', 'invalid_asset', 'cap_exceeded', 'name_missing', 'name_expired', 'name_forbidden', 'name_unavailable', 'location_missing', 'location_expired', 'location_forbidden', 'location_unavailable', 'reference_unknown', 'reference_unavailable', 'unsafe_reference_id', 'forbidden_structure', 'unavailable_structure']);
const allowedLocation = new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'referenceVersion', 'dockingPermission', 'observedAt', 'expiresAt', 'system', 'region']);
const allowedRoot = new Set(['kind', 'id', 'locationType', 'locationFlag']);
const allowedBreadcrumb = new Set(['itemId', 'typeId', 'name']);
const allowedFitted = new Set(['classification', 'rawFlag', 'reason']);
const text = (input: unknown, field: string, max: number): string => {
if (typeof input !== 'string' || input.length === 0 || input.length > max) throw new TypeError(`${field} is invalid`);
return input;
};
const id = (input: unknown, field: string): DecimalId => { assertDecimalId(input, field); return input; };
const location = (input: unknown, depth: number): void => {
if (depth > 3 || !input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('location evidence is invalid');
const candidate = input as Partial<LocationReference>;
if (Object.keys(candidate).some((key) => !allowedLocation.has(key))) throw new TypeError('location evidence has unknown fields');
if (!['station', 'system', 'region', 'structure', 'unknown'].includes(candidate.kind ?? '')) throw new TypeError('location evidence kind is invalid');
id(candidate.id, 'location evidence ID');
if (candidate.name !== null && candidate.name !== undefined) text(candidate.name, 'location evidence name', MAX_ENRICHMENT_NAME_LENGTH);
if (candidate.parentId !== null && candidate.parentId !== undefined) id(candidate.parentId, 'location evidence parent ID');
if (!['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(candidate.evidence ?? '')) throw new TypeError('location evidence provenance is invalid');
const privateEvidence = candidate.evidence === 'private_structure_access';
if (privateEvidence) {
if (!options?.authority?.scopes.includes('esi-universe.read_structures.v1')) throw new TypeError('private structure evidence requires pinned structure scope');
if (candidate.kind !== 'structure' || candidate.sourceVerified !== true) throw new TypeError('private structure evidence source is invalid');
} else if (candidate.kind === 'structure' && candidate.evidence !== 'unknown') throw new TypeError('structure evidence source is invalid');
if (candidate.sourceVerified !== null && candidate.sourceVerified !== undefined && typeof candidate.sourceVerified !== 'boolean') throw new TypeError('location evidence verification is invalid');
if (candidate.kind === 'station' && ((candidate.evidence === 'verified' && candidate.sourceVerified !== true) || (candidate.evidence === 'system_listed' && candidate.sourceVerified !== false))) throw new TypeError('station evidence source is inconsistent');
if ((candidate.kind === 'system' || candidate.kind === 'region') && (candidate.evidence !== 'verified' || candidate.sourceVerified !== true)) throw new TypeError('public reference evidence source is inconsistent');
if (candidate.kind === 'unknown' && (candidate.evidence !== 'unknown' || candidate.sourceVerified !== null)) throw new TypeError('unknown location evidence source is inconsistent');
if (candidate.kind === 'structure') {
if (candidate.name === undefined || candidate.observedAt === undefined || candidate.expiresAt === undefined || typeof candidate.observedAt !== 'number' || !Number.isSafeInteger(candidate.observedAt) || typeof candidate.expiresAt !== 'number' || !Number.isSafeInteger(candidate.expiresAt) || candidate.observedAt < 0 || candidate.expiresAt < candidate.observedAt || candidate.expiresAt > 8_640_000_000_000_000) throw new TypeError('structure evidence freshness is invalid');
if (candidate.evidence === 'unknown' && (candidate.name !== null || candidate.sourceVerified !== null)) throw new TypeError('denied structure evidence contains private facts');
} else if (candidate.observedAt !== undefined || candidate.expiresAt !== undefined) throw new TypeError('public location evidence contains structure freshness');
if (candidate.system === undefined || candidate.region === undefined) throw new TypeError('location evidence hierarchy is incomplete');
text(candidate.referenceVersion, 'location evidence reference version', 256);
if (candidate.dockingPermission !== 'unknown') throw new TypeError('location evidence docking permission is invalid');
if (candidate.system !== null && candidate.system !== undefined) location(candidate.system, depth + 1);
if (candidate.region !== null && candidate.region !== undefined) location(candidate.region, depth + 1);
};
for (const row of value) {
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new TypeError('location enrichment row is invalid');
const candidate = row as Partial<EnrichedLocationNode>;
if (Object.keys(candidate).some((key) => !allowed.has(key))) throw new TypeError('location enrichment row has unknown fields');
const itemId = id(candidate.itemId, 'location row item ID');
if (typeof candidate.characterId !== 'number' || !Number.isSafeInteger(candidate.characterId) || candidate.characterId <= 0 || typeof candidate.typeId !== 'number' || !Number.isSafeInteger(candidate.typeId) || candidate.typeId <= 0 || typeof candidate.quantity !== 'number' || !Number.isSafeInteger(candidate.quantity) || candidate.quantity < 0) throw new TypeError('location enrichment source facts are invalid');
id(candidate.locationId, 'location row location ID');
if (!['station', 'solar_system', 'item', 'other'].includes(text(candidate.locationType, 'location row type', 32)) || text(candidate.locationFlag, 'location row flag', 128).length > 128) throw new TypeError('location enrichment location facts are invalid');
if (candidate.isSingleton !== undefined && typeof candidate.isSingleton !== 'boolean') throw new TypeError('location row singleton fact is invalid');
if (candidate.isBlueprintCopy !== undefined && typeof candidate.isBlueprintCopy !== 'boolean') throw new TypeError('location row blueprint fact is invalid');
if (candidate.parentItemId !== null && candidate.parentItemId !== undefined) id(candidate.parentItemId, 'location row parent ID');
if (!candidate.root || typeof candidate.root !== 'object' || Array.isArray(candidate.root)) throw new TypeError('location enrichment root is invalid');
const root = candidate.root as Partial<ContainmentRoot>;
if (Object.keys(root).some((key) => !allowedRoot.has(key))) throw new TypeError('location root has unknown fields');
if (!['external', 'item', 'unresolved'].includes(root.kind ?? '')) throw new TypeError('location enrichment root kind is invalid');
if (root.id !== null && root.id !== undefined) id(root.id, 'location root ID');
if (root.kind !== 'unresolved' && (root.id === null || root.id === undefined)) throw new TypeError('location root ID is required');
text(root.locationType, 'location root type', 32); text(root.locationFlag, 'location root flag', 128);
if (candidate.depth !== null && (typeof candidate.depth !== 'number' || !Number.isSafeInteger(candidate.depth) || candidate.depth < 0 || candidate.depth > DEFAULT_CONTAINMENT_DEPTH)) throw new TypeError('location row depth is invalid');
if (!Array.isArray(candidate.breadcrumbs) || candidate.breadcrumbs.length > DEFAULT_CONTAINMENT_DEPTH + 1) throw new TypeError('location breadcrumbs are oversized');
for (const breadcrumb of candidate.breadcrumbs) {
if (!breadcrumb || typeof breadcrumb !== 'object') throw new TypeError('location breadcrumb is invalid');
const value = breadcrumb as Partial<Breadcrumb>;
if (Object.keys(value).some((key) => !allowedBreadcrumb.has(key))) throw new TypeError('location breadcrumb has unknown fields');
id(value.itemId, 'breadcrumb item ID');
if (typeof value.typeId !== 'number' || !Number.isSafeInteger(value.typeId) || value.typeId <= 0) throw new TypeError('breadcrumb type ID is invalid');
if (value.name !== null && value.name !== undefined) text(value.name, 'breadcrumb name', MAX_ENRICHMENT_NAME_LENGTH);
}
if (!candidate.fitted || typeof candidate.fitted !== 'object' || Object.keys(candidate.fitted).some((key) => !allowedFitted.has(key)) || !['fitted', 'not_fitted', 'unknown'].includes(candidate.fitted.classification ?? '') || !['slot_flag', 'ordinary_location_flag', 'ambiguous_autofit', 'ambiguous_special_bay'].includes(candidate.fitted.reason ?? '')) throw new TypeError('fitted evidence is invalid');
text(candidate.fitted.rawFlag, 'fitted raw flag', 128);
if (!['fitted', 'not_fitted', 'unknown'].includes(candidate.fittedClassification ?? '') || (candidate.isFitted !== null && typeof candidate.isFitted !== 'boolean')) throw new TypeError('fitted projection is invalid');
if (!Array.isArray(candidate.unresolved) || candidate.unresolved.length > 24) throw new TypeError('location unresolved reasons are oversized');
if (candidate.unresolved.some((reason: unknown) => typeof reason !== 'string' || !reasons.has(reason))) throw new TypeError('location unresolved reason is invalid');
if (candidate.customName !== null && candidate.customName !== undefined) text(candidate.customName, 'location custom name', MAX_ENRICHMENT_NAME_LENGTH);
if (!statuses.has(candidate.nameStatus ?? '') || !statuses.has(candidate.locationStatus ?? '')) throw new TypeError('location enrichment status is invalid');
if (candidate.position !== null && candidate.position !== undefined && (!candidate.position || ![candidate.position.x, candidate.position.y, candidate.position.z].every((coordinate) => typeof coordinate === 'number' && Number.isFinite(coordinate) && Math.abs(coordinate) <= 1e30))) throw new TypeError('location enrichment position is invalid');
location(candidate.location, 0);
void itemId;
}
}
export function isDecimalId(value: unknown): value is DecimalId {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return false;
try { return BigInt(value) <= MAX_DECIMAL_ID; } catch { return false; }
}
export function assertDecimalId(value: unknown, field = 'ID'): asserts value is DecimalId {
if (!isDecimalId(value)) throw new TypeError(`${field} must be a positive signed-int64 decimal string`);
}
export function canonicalDecimalIds(values: readonly unknown[], field = 'IDs'): DecimalId[] {
if (!Array.isArray(values)) throw new TypeError(`${field} must be an array`);
const output = values.map((value, index) => {
assertDecimalId(value, `${field}[${index}]`);
return value;
});
return [...new Set(output)].sort(compareDecimalIds);
}
export function compareDecimalIds(left: string, right: string): number {
const leftTrimmed = left.replace(/^0+(?=\d)/, '');
const rightTrimmed = right.replace(/^0+(?=\d)/, '');
return leftTrimmed.length - rightTrimmed.length || (leftTrimmed < rightTrimmed ? -1 : leftTrimmed > rightTrimmed ? 1 : 0);
}
export function decimalIdToSafeNumber(id: DecimalId): number | null {
assertDecimalId(id);
const value = BigInt(id);
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
}
function getMapValue<T>(map: ReadonlyMap<string, T> | Readonly<Record<string, T>> | undefined, key: string): T | undefined {
if (!map) return undefined;
if (map instanceof Map) return map.get(key);
return (map as Readonly<Record<string, T>>)[key];
}
function getTypeName(map: ContainmentInput['typeNames'], key: number): string | null {
if (!map) return null;
const value = map instanceof Map ? map.get(key) : (map as Readonly<Record<string, string>>)[String(key)];
return typeof value === 'string' && value.trim() ? value : null;
}
function safeCharacterId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
export function validateAuthorityContext(authority: unknown): InventoryLocationAuthority {
if (!authority || typeof authority !== 'object') throw new TypeError('authority context is required');
const candidate = authority as Partial<InventoryLocationAuthority>;
if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.principal !== 'string' || !candidate.principal || candidate.principal.length > 256) throw new TypeError('authority identity is invalid');
if (!safeCharacterId(candidate.characterId)) throw new TypeError('authority character ID is invalid');
const characterId = candidate.characterId;
if (!safeCharacterId(characterId)) throw new TypeError('authority character ID is invalid');
const scopes = candidate.scopes;
if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope) => typeof scope !== 'string' || !scope || scope.length > 128)) throw new TypeError('authority scopes are invalid');
const grantRevision = candidate.grantRevision;
const authorizationEpoch = candidate.authorizationEpoch;
const credentialVersion = candidate.credentialVersion;
if (!safeCharacterId(grantRevision) || !safeCharacterId(authorizationEpoch) || !safeCharacterId(credentialVersion)) throw new TypeError('authority counters are invalid');
return {
userId: candidate.userId,
principal: candidate.principal,
characterId,
scopes: [...new Set(scopes)].sort(),
grantRevision,
authorizationEpoch,
credentialVersion,
};
}
function validateAsset(asset: unknown, characterId: number): asset is Asset {
if (!asset || typeof asset !== 'object') return false;
const candidate = asset as Partial<Asset>;
return isDecimalId(candidate.itemId) && isDecimalId(candidate.locationId) && candidate.characterId === characterId && safeCharacterId(candidate.characterId) &&
typeof candidate.typeId === 'number' && Number.isSafeInteger(candidate.typeId) && candidate.typeId > 0 &&
typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0 &&
typeof candidate.locationType === 'string' && typeof candidate.locationFlag === 'string' &&
(candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean') &&
(candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean');
}
/** Resolve only the local parent graph. It never infers a parent from a numeric ID unless ESI says location_type=item. */
export function resolveContainment(input: ContainmentInput): ContainmentResult {
if (!input || !Array.isArray(input.assets)) throw new TypeError('assets must be an array');
const characterId = input.characterId ?? (input.assets[0] && typeof input.assets[0] === 'object' ? (input.assets[0] as Partial<Asset>).characterId : undefined);
if (!safeCharacterId(characterId)) throw new TypeError('containment character ID is invalid');
const maxDepth = input.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
if (input.assets.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`containment item cap exceeded (${MAX_CONTAINMENT_ITEMS})`);
const duplicateIds = new Set<DecimalId>();
const byId = new Map<DecimalId, Asset>();
for (const asset of input.assets) {
if (!validateAsset(asset, characterId)) continue;
if (byId.has(asset.itemId)) duplicateIds.add(asset.itemId);
else byId.set(asset.itemId, asset);
}
type Computed = { parentItemId: DecimalId | null; root: ContainmentRoot; depth: number | null; breadcrumbs: Breadcrumb[]; reasons: Set<ContainmentUnresolvedReason> };
type Frame = { id: DecimalId; asset: Asset };
const computed = new Map<DecimalId, Computed>();
const names = input.names;
// A walk is at most maxDepth + 2 frames. The explicit budget makes the
// bounded-work guarantee visible even for a maximum-sized snapshot.
const maxWork = MAX_CONTAINMENT_ITEMS * (DEFAULT_CONTAINMENT_DEPTH + 2);
let work = 0;
const unresolvedRoot = (asset: Asset): ContainmentRoot => ({ kind: 'unresolved', id: asset.locationType === 'item' ? asset.locationId : null, locationType: asset.locationType, locationFlag: asset.locationFlag });
const setTerminal = (frame: Frame, reason: ContainmentUnresolvedReason): void => {
computed.set(frame.id, {
parentItemId: frame.asset.locationType === 'item' ? frame.asset.locationId : null,
root: unresolvedRoot(frame.asset), depth: null, breadcrumbs: [], reasons: new Set([reason]),
});
};
const resolveFrom = (start: DecimalId): void => {
if (computed.has(start)) return;
const path: Frame[] = [];
const pathIndex = new Map<DecimalId, number>();
let current = start;
while (!computed.has(current)) {
if (++work > maxWork) {
const frame = path[path.length - 1] ?? { id: start, asset: byId.get(start)! };
setTerminal(frame, 'cap_exceeded');
break;
}
const cycleAt = pathIndex.get(current);
if (cycleAt !== undefined) {
for (const frame of path.slice(cycleAt)) setTerminal(frame, 'cycle');
break;
}
const asset = byId.get(current);
if (!asset) break;
pathIndex.set(current, path.length);
const frame = { id: current, asset };
path.push(frame);
if (asset.locationType !== 'item') {
computed.set(current, { parentItemId: null, root: { kind: 'external', id: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag }, depth: 0, breadcrumbs: [], reasons: new Set() });
break;
}
const parent = byId.get(asset.locationId);
if (!parent) { setTerminal(frame, 'missing_parent'); break; }
if (duplicateIds.has(asset.locationId)) { setTerminal(frame, 'parent_unresolved'); break; }
// One extra parent frame lets a node exactly at maxDepth resolve; a
// longer chain is deterministically unresolved without walking to its root.
if (path.length > maxDepth + 1) { setTerminal(frame, 'depth_cap'); break; }
current = asset.locationId;
}
for (let index = path.length - 1; index >= 0; index -= 1) {
const frame = path[index]!;
if (computed.has(frame.id)) continue;
const asset = frame.asset;
if (asset.locationType !== 'item') continue;
const parent = byId.get(asset.locationId);
const parentResult = parent ? computed.get(parent.itemId) : undefined;
const reasons = new Set<ContainmentUnresolvedReason>();
if (!parent || !parentResult) {
setTerminal(frame, 'parent_unresolved');
continue;
}
const depth = parentResult.depth === null ? null : parentResult.depth + 1;
const breadcrumbs = [...parentResult.breadcrumbs, { itemId: parent.itemId, typeId: parent.typeId, name: getMapValue(names, parent.itemId) ?? getTypeName(input.typeNames, parent.typeId) }];
for (const reason of parentResult.reasons) reasons.add(reason === 'missing_parent' ? 'parent_unresolved' : reason);
let root: ContainmentRoot = parentResult.root.kind === 'item' || parentResult.root.kind === 'external' ? parentResult.root : unresolvedRoot(asset);
let finalDepth = depth;
if (depth === null || depth > maxDepth) { reasons.add(depth === null ? 'parent_unresolved' : 'depth_cap'); finalDepth = null; root = unresolvedRoot(asset); }
computed.set(frame.id, { parentItemId: asset.locationId, root, depth: finalDepth, breadcrumbs, reasons });
}
};
for (const id of [...byId.keys()].sort(compareDecimalIds)) if (!duplicateIds.has(id)) resolveFrom(id);
const nodes: ContainmentNode[] = input.assets.map((source) => {
const record = source !== null && typeof source === 'object' ? source as Partial<Asset> : {};
const valid = validateAsset(source, characterId);
const rawItemId = typeof record.itemId === 'string' ? record.itemId : '';
const rawLocationId = typeof record.locationId === 'string' ? record.locationId : '';
const rawLocationType = typeof record.locationType === 'string' ? record.locationType : 'unknown';
const rawLocationFlag = typeof record.locationFlag === 'string' ? record.locationFlag : '';
const detail = valid ? computed.get(record.itemId!) : undefined;
const reasons = new Set<ContainmentUnresolvedReason>(detail?.reasons ?? []);
if (!isDecimalId(rawItemId)) reasons.add('invalid_item_id');
if (!isDecimalId(rawLocationId)) reasons.add('invalid_location_id');
if (!valid) reasons.add('invalid_asset');
if (valid && duplicateIds.has(record.itemId!)) reasons.add('duplicate_item_id');
const root = detail?.root ?? { kind: 'unresolved' as const, id: isDecimalId(rawLocationId) ? rawLocationId : null, locationType: rawLocationType, locationFlag: rawLocationFlag };
const fitted = classifyFittedFlag(rawLocationFlag);
const base = valid ? source : {
characterId: safeCharacterId(record.characterId) ? record.characterId : characterId,
typeId: typeof record.typeId === 'number' && Number.isSafeInteger(record.typeId) && record.typeId > 0 ? record.typeId : 0,
quantity: typeof record.quantity === 'number' && Number.isSafeInteger(record.quantity) && record.quantity >= 0 ? record.quantity : 0,
locationType: rawLocationType,
locationFlag: rawLocationFlag,
...(typeof record.isSingleton === 'boolean' ? { isSingleton: record.isSingleton } : {}),
...(typeof record.isBlueprintCopy === 'boolean' ? { isBlueprintCopy: record.isBlueprintCopy } : {}),
};
return {
...base,
itemId: isDecimalId(rawItemId) ? rawItemId : '', locationId: isDecimalId(rawLocationId) ? rawLocationId : '',
parentItemId: detail?.parentItemId ?? null, root, depth: detail?.depth ?? null, breadcrumbs: detail?.breadcrumbs ?? [], fitted,
fittedClassification: fitted.classification, isFitted: fitted.classification === 'fitted' ? true : fitted.classification === 'not_fitted' ? false : null,
unresolved: [...reasons].sort(),
} as ContainmentNode;
}).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
const roots = [...new Map(nodes.filter((node) => node.root.kind === 'external' || node.root.kind === 'item').map((node) => [`${node.root.kind}:${node.root.id}`, node.root])).values()]
.sort((left, right) => compareDecimalIds(left.id ?? '1', right.id ?? '1'));
const unresolved = nodes.filter((node) => node.unresolved.length > 0).map((node) => ({ itemId: node.itemId || null, reasons: node.unresolved }));
return { nodes, roots, unresolved, complete: unresolved.length === 0, maxDepth };
}
function finiteTimestamp(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new TypeError(`${field} is invalid`);
return value;
}
function validateObservationIds(ids: unknown, field: string): DecimalId[] {
if (!Array.isArray(ids)) throw new TypeError(`${field} must be an array`);
// Check the raw container before touching any element. This is important for
// duplicate-heavy/proxy inputs and keeps malformed work below the helper cap.
if (ids.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} IDs`);
return ids.map((value, index) => { assertDecimalId(value, `${field}[${index}]`); return value; });
}
function validateBoundedEntries(entries: unknown, field: string): asserts entries is readonly unknown[] {
if (!Array.isArray(entries)) throw new TypeError(`${field} must be an array`);
if (entries.length > MAX_ENRICHMENT_IDS) throw new RangeError(`${field} exceeds ${MAX_ENRICHMENT_IDS} entries`);
}
function observationState(value: unknown, fallback: EnrichmentObservationState = 'observed'): EnrichmentObservationState {
const state = value === undefined ? fallback : value;
if (state !== 'observed' && state !== 'not_requested' && state !== 'forbidden' && state !== 'unavailable') throw new TypeError('invalid enrichment observation state');
return state;
}
export type ObservationPlan = {
state: EnrichmentObservationState;
requestedIds: readonly DecimalId[];
};
export type ObservationBatchPlan = ObservationPlan & {
batches: readonly (NamesObservation | LocationsObservation)[];
};
function mergeObservationState(states: readonly EnrichmentObservationState[]): EnrichmentObservationState {
if (states.length === 0) return 'not_requested';
if (states.every((state) => state === 'observed')) return 'observed';
if (states.includes('forbidden')) return 'forbidden';
if (states.includes('unavailable')) return 'unavailable';
return 'not_requested';
}
/** Validate each bounded helper response while retaining all batches in the plan. */
export function normalizeObservationBatches(
observations: readonly (NamesObservation | LocationsObservation)[] | undefined,
kind: 'names' | 'locations',
explicitState?: EnrichmentObservationState,
explicitRequestedIds?: readonly DecimalId[],
): ObservationBatchPlan {
if (!observations || observations.length === 0) {
const plan = normalizeObservationPlan(undefined, kind, explicitState, explicitRequestedIds);
return { ...plan, batches: [] };
}
if (!Number.isSafeInteger(observations.length) || observations.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new RangeError(`${kind} observation batches exceed the generation bound`);
const plans = observations.map((observation) => normalizeObservationPlan(observation, kind));
const requested = plans.flatMap((plan) => plan.requestedIds);
if (new Set(requested).size !== requested.length) throw new TypeError(`${kind} observation batches contain duplicate requested IDs`);
const canonicalRequested = canonicalDecimalIds(requested, `${kind}RequestedIds`);
if (explicitRequestedIds !== undefined) {
if (!Array.isArray(explicitRequestedIds) || explicitRequestedIds.length > MAX_CONTAINMENT_ITEMS) throw new RangeError(`${kind}RequestedIds exceeds ${MAX_CONTAINMENT_ITEMS} IDs`);
const explicit = canonicalDecimalIds(explicitRequestedIds.map((value, index) => { assertDecimalId(value, `${kind}RequestedIds[${index}]`); return value; }), `${kind}RequestedIds`);
if (stableSerialize(explicit) !== stableSerialize(canonicalRequested)) throw new TypeError(`${kind} requested IDs conflict with observation batches`);
}
const states = plans.map((plan) => plan.state);
const state = mergeObservationState(states);
if (explicitState !== undefined && explicitState !== state) throw new TypeError(`${kind} state conflicts with observation batches`);
return { state, requestedIds: canonicalRequested, batches: observations };
}
export function normalizeObservationPlan(
observation: NamesObservation | LocationsObservation | undefined,
kind: 'names' | 'locations',
explicitState?: EnrichmentObservationState,
explicitRequestedIds?: readonly DecimalId[],
): ObservationPlan {
const rawExplicitRequested = explicitRequestedIds === undefined ? undefined : validateObservationIds(explicitRequestedIds, `${kind}RequestedIds`);
if (rawExplicitRequested && new Set(rawExplicitRequested).size !== rawExplicitRequested.length) throw new TypeError(`${kind} requested IDs are duplicated`);
const requested = rawExplicitRequested === undefined ? undefined : canonicalDecimalIds(rawExplicitRequested, `${kind}RequestedIds`);
if (observation) {
const embedded = observationState(observation.state);
observationPin(observation, kind);
if (explicitState !== undefined && explicitState !== embedded) throw new TypeError(`${kind} state conflicts with observation state`);
const observedIds = canonicalDecimalIds(observation.requestedIds, `${kind}.requestedIds`);
if (requested && stableSerialize(requested) !== stableSerialize(observedIds)) throw new TypeError(`${kind} requested IDs conflict with observation`);
return { state: embedded, requestedIds: observedIds };
}
const state = observationState(explicitState, requested && requested.length > 0 ? 'unavailable' : 'not_requested');
if (state === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
return { state, requestedIds: requested ?? [] };
}
export function observationPin(observation: NamesObservation | LocationsObservation | undefined, kind: 'names' | 'locations', state: EnrichmentObservationState = 'not_requested'): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
if (!observation) {
const effectiveState = observationState(state, 'not_requested');
if (effectiveState === 'observed') throw new TypeError(`${kind} observed state requires an observation`);
return { state: effectiveState, requestedIds: [], resolvedIds: [], missingIds: [], retrievedAt: null, expiresAt: null, contentDigest: digest(stableSerialize({ state: effectiveState, kind })) };
}
const rawRequestedIds = validateObservationIds(observation.requestedIds, `${kind}.requestedIds`);
if (new Set(rawRequestedIds).size !== rawRequestedIds.length) throw new TypeError(`${kind} observation requested IDs are duplicated`);
const requestedIds = canonicalDecimalIds(rawRequestedIds);
const rawMissingIds = validateObservationIds(observation.missing, `${kind}.missing`);
if (new Set(rawMissingIds).size !== rawMissingIds.length) throw new TypeError(`${kind} observation missing IDs are duplicated`);
const rawNames = (observation as NamesObservation).names;
const rawLocations = (observation as LocationsObservation).locations;
if (kind === 'names') validateBoundedEntries(rawNames, `${kind}.names`);
else validateBoundedEntries(rawLocations, `${kind}.locations`);
const returned = kind === 'names'
? rawNames.map((entry: { itemId: DecimalId }) => entry.itemId)
: rawLocations.map((entry: { itemId: DecimalId }) => entry.itemId);
const resolvedIds = canonicalDecimalIds(returned, `${kind}.resolvedIds`);
const missingIds = canonicalDecimalIds(rawMissingIds);
const requestedSet = new Set(requestedIds);
if (new Set([...resolvedIds, ...missingIds]).size !== resolvedIds.length + missingIds.length) throw new TypeError(`${kind} observation overlaps resolved and missing IDs`);
if ([...resolvedIds, ...missingIds].some((id) => !requestedSet.has(id))) throw new TypeError(`${kind} observation contains an unrequested ID`);
const expectedComplete = missingIds.length === 0 && resolvedIds.length === requestedIds.length;
if (observation.complete !== expectedComplete) throw new TypeError(`${kind} observation completeness is inconsistent`);
if (kind === 'names') {
const names = (observation as NamesObservation).names;
if (new Set(names.map((entry) => entry.itemId)).size !== names.length || names.some((entry) => !isDecimalId(entry.itemId) || typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_ENRICHMENT_NAME_LENGTH)) throw new TypeError('names observation contains invalid or duplicate names');
} else {
const locations = (observation as LocationsObservation).locations;
if (new Set(locations.map((entry) => entry.itemId)).size !== locations.length || locations.some((entry) => !isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z))) throw new TypeError('locations observation contains invalid or duplicate positions');
}
const retrievedAt = finiteTimestamp(observation.retrievedAt, `${kind}.retrievedAt`);
const expiresAt = finiteTimestamp(observation.expiresAt, `${kind}.expiresAt`);
if (expiresAt < retrievedAt) throw new TypeError(`${kind} observation expiry precedes retrieval`);
const effectiveState = observationState(observation.state);
const content = kind === 'names'
? (observation as NamesObservation).names.map((entry: { itemId: DecimalId; name: string }) => ({ itemId: entry.itemId, name: entry.name })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId))
: (observation as LocationsObservation).locations.map((entry: { itemId: DecimalId; position: { x: number; y: number; z: number } }) => ({ itemId: entry.itemId, position: entry.position })).sort((left, right) => compareDecimalIds(left.itemId, right.itemId));
const eligibleIds = effectiveState === 'observed' ? resolvedIds : [];
const eligibleMissing = effectiveState === 'observed' ? missingIds : [];
const contentDigest = digest(stableSerialize(effectiveState === 'observed' ? content : { state: effectiveState, requestedIds }));
return { state: effectiveState, requestedIds, resolvedIds: eligibleIds, missingIds: eligibleMissing, retrievedAt, expiresAt, contentDigest };
}
function observationBatchPin(
observations: readonly (NamesObservation | LocationsObservation)[],
kind: 'names' | 'locations',
state: EnrichmentObservationState,
requestedIds: readonly DecimalId[],
): ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'] {
// Preserve each batch's own state/content digest. An unavailable batch must
// not erase a successful neighboring batch from the generation pin.
const pins = observations.map((observation) => observationPin(observation, kind));
const resolvedIds = canonicalDecimalIds(pins.flatMap((pin) => pin.resolvedIds), `${kind}.resolvedIds`);
const missingIds = canonicalDecimalIds(pins.flatMap((pin) => pin.missingIds), `${kind}.missingIds`);
const times = pins.flatMap((pin) => [pin.retrievedAt, pin.expiresAt]).filter((value): value is number => value !== null);
return {
state,
requestedIds: [...requestedIds],
resolvedIds,
missingIds,
retrievedAt: times.length ? Math.min(...pins.flatMap((pin) => pin.retrievedAt).filter((value): value is number => value !== null)) : null,
expiresAt: times.length ? Math.min(...pins.flatMap((pin) => pin.expiresAt).filter((value): value is number => value !== null)) : null,
contentDigest: digest(stableSerialize({ kind, state, requestedIds, batches: pins.map((pin) => ({ ...pin, requestedIds: undefined })) })),
batches: pins,
} as ReferenceEnrichmentPin['names'] | ReferenceEnrichmentPin['locations'];
}
export function createReferenceEnrichmentPin(args: {
assetGeneration: string;
authority: InventoryLocationAuthority;
referenceVersion: string;
names?: NamesObservation;
namesBatches?: readonly NamesObservation[];
locations?: LocationsObservation;
locationsBatches?: readonly LocationsObservation[];
namesState?: EnrichmentObservationState;
locationsState?: EnrichmentObservationState;
namesRequestedIds?: readonly DecimalId[];
locationsRequestedIds?: readonly DecimalId[];
structureRevision?: string;
structureObservations?: readonly StructureObservationPin[];
referenceFacts?: readonly ReferenceLocationFactPin[];
containmentDepth?: number;
}): ReferenceEnrichmentPin {
if (typeof args.assetGeneration !== 'string' || !args.assetGeneration) throw new TypeError('asset generation is required');
if (typeof args.referenceVersion !== 'string' || !args.referenceVersion) throw new TypeError('reference version is required');
const containmentDepth = args.containmentDepth ?? DEFAULT_CONTAINMENT_DEPTH;
if (!Number.isSafeInteger(containmentDepth) || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH) throw new RangeError('containment depth is invalid');
const authority = validateAuthorityContext(args.authority);
const namesBatches = args.namesBatches ?? (args.names ? [args.names] : []);
const locationsBatches = args.locationsBatches ?? (args.locations ? [args.locations] : []);
const namesPlan = normalizeObservationBatches(namesBatches, 'names', args.namesState, args.namesRequestedIds);
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', args.locationsState, args.locationsRequestedIds);
const namesObservation = args.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined);
const locationsObservation = args.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined);
const names = namesBatches.length > 1
? observationBatchPin(namesBatches, 'names', namesPlan.state, namesPlan.requestedIds) as ReferenceEnrichmentPin['names']
: observationPin(namesObservation, 'names', namesPlan.state) as ReferenceEnrichmentPin['names'];
const locations = locationsBatches.length > 1
? observationBatchPin(locationsBatches, 'locations', locationsPlan.state, locationsPlan.requestedIds) as ReferenceEnrichmentPin['locations']
: observationPin(locationsObservation, 'locations', locationsPlan.state) as ReferenceEnrichmentPin['locations'];
if (!namesObservation && namesBatches.length <= 1) {
names.requestedIds = namesPlan.requestedIds;
names.contentDigest = digest(stableSerialize({ state: names.state, requestedIds: names.requestedIds }));
}
if (!locationsObservation && locationsBatches.length <= 1) {
locations.requestedIds = locationsPlan.requestedIds;
locations.contentDigest = digest(stableSerialize({ state: locations.state, requestedIds: locations.requestedIds }));
}
const structures = args.structureObservations?.map((observation) => ({ ...observation })) ?? undefined;
if (structures && structures.length > 1_000) throw new RangeError('structure observations exceed the generation bound');
const referenceFacts = args.referenceFacts?.map((fact) => ({ ...fact })).sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)) ?? undefined;
if (referenceFacts && referenceFacts.length > 3_000) throw new RangeError('reference location facts exceed the generation bound');
return { version: 1, assetGeneration: args.assetGeneration, authority, referenceVersion: args.referenceVersion, containmentDepth, names, locations, ...(structures === undefined ? {} : { structures }), ...(referenceFacts === undefined ? {} : { referenceFacts, referenceFactsDigest: digest(stableSerialize(referenceFacts)) }), structureRevision: args.structureRevision ?? 'none' };
}
/** Validate an immutable generation pin at the persistence boundary. */
export function validateReferenceEnrichmentPin(value: unknown, expected?: { assetGeneration?: string; referenceVersion?: string; authority?: InventoryLocationAuthority }): asserts value is ReferenceEnrichmentPin {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('enrichment pin is invalid');
const candidate = value as Partial<ReferenceEnrichmentPin>;
const allowedPin = new Set(['version', 'assetGeneration', 'authority', 'referenceVersion', 'containmentDepth', 'names', 'locations', 'structures', 'referenceFacts', 'referenceFactsDigest', 'structureRevision']);
if (Object.keys(candidate).some((key) => !allowedPin.has(key))) throw new TypeError('enrichment pin has unknown fields');
if (candidate.version !== 1 || typeof candidate.assetGeneration !== 'string' || candidate.assetGeneration.length === 0 || candidate.assetGeneration.length > 256) throw new TypeError('enrichment pin generation is invalid');
if (expected?.assetGeneration !== undefined && candidate.assetGeneration !== expected.assetGeneration) throw new TypeError('enrichment pin generation does not match');
if (typeof candidate.referenceVersion !== 'string' || candidate.referenceVersion.length === 0 || candidate.referenceVersion.length > 256) throw new TypeError('enrichment pin reference version is invalid');
if (expected?.referenceVersion !== undefined && candidate.referenceVersion !== expected.referenceVersion) throw new TypeError('enrichment pin reference version does not match');
if (!candidate.authority || typeof candidate.authority !== 'object' || Array.isArray(candidate.authority) || Object.keys(candidate.authority as object).some((key) => !new Set(['userId', 'principal', 'characterId', 'scopes', 'grantRevision', 'authorizationEpoch', 'credentialVersion']).has(key))) throw new TypeError('enrichment pin authority has unknown fields');
const rawAuthority = candidate.authority as Partial<InventoryLocationAuthority>;
const rawScopes = rawAuthority.scopes;
if (!Array.isArray(rawScopes) || stableSerialize(rawScopes) !== stableSerialize([...rawScopes].sort())) throw new TypeError('enrichment pin authority scopes are not canonical');
const authority = validateAuthorityContext(candidate.authority);
if (authority.userId.length > 256 || authority.principal.length > 256 || authority.scopes.length > 64 || authority.scopes.some((scope) => scope.length > 128)) throw new TypeError('enrichment pin authority bounds are invalid');
if (expected?.authority) {
const required = validateAuthorityContext(expected.authority);
if (authority.userId !== required.userId || authority.principal !== required.principal || authority.characterId !== required.characterId || authority.grantRevision !== required.grantRevision || authority.authorizationEpoch !== required.authorizationEpoch || authority.credentialVersion !== required.credentialVersion || stableSerialize(authority.scopes) !== stableSerialize(required.scopes)) throw new TypeError('enrichment pin authority does not match');
}
const containmentDepth = candidate.containmentDepth;
if (!Number.isSafeInteger(containmentDepth) || typeof containmentDepth !== 'number' || containmentDepth < 0 || containmentDepth > DEFAULT_CONTAINMENT_DEPTH || typeof candidate.structureRevision !== 'string' || candidate.structureRevision.length === 0 || candidate.structureRevision.length > 256) throw new TypeError('enrichment pin bounds are invalid');
const validatePart = (part: unknown, field: 'names' | 'locations'): void => {
if (!part || typeof part !== 'object' || Array.isArray(part)) throw new TypeError(`enrichment pin ${field} is invalid`);
const item = part as ReferenceEnrichmentPin['names'];
const allowedPart = new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest', 'batches']);
if (Object.keys(item).some((key) => !allowedPart.has(key))) throw new TypeError(`enrichment pin ${field} has unknown fields`);
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(item.state)) throw new TypeError(`enrichment pin ${field} state is invalid`);
const lists = [item.requestedIds, item.resolvedIds, item.missingIds];
if (lists.some((list) => !Array.isArray(list) || list.length > MAX_CONTAINMENT_ITEMS)) throw new TypeError(`enrichment pin ${field} IDs are oversized`);
const requested = canonicalDecimalIds(item.requestedIds, `${field}.requestedIds`);
const resolved = canonicalDecimalIds(item.resolvedIds, `${field}.resolvedIds`);
const missing = canonicalDecimalIds(item.missingIds, `${field}.missingIds`);
if (stableSerialize(requested) !== stableSerialize(item.requestedIds) || stableSerialize(resolved) !== stableSerialize(item.resolvedIds) || stableSerialize(missing) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} IDs are not canonical`);
const requestedSet = new Set(requested);
if ([...resolved, ...missing].some((id) => !requestedSet.has(id)) || new Set([...resolved, ...missing]).size !== resolved.length + missing.length) throw new TypeError(`enrichment pin ${field} IDs are inconsistent`);
if (typeof item.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(item.contentDigest)) throw new TypeError(`enrichment pin ${field} digest is invalid`);
const times = [item.retrievedAt, item.expiresAt];
if (times.some((time) => time !== null && (typeof time !== 'number' || !Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (item.retrievedAt === null) !== (item.expiresAt === null) || (item.retrievedAt !== null && item.expiresAt !== null && item.expiresAt < item.retrievedAt)) throw new TypeError(`enrichment pin ${field} freshness is invalid`);
if (item.state === 'not_requested' && (item.requestedIds.length !== 0 || item.resolvedIds.length !== 0 || item.missingIds.length !== 0 || item.retrievedAt !== null || item.expiresAt !== null)) throw new TypeError(`enrichment pin ${field} not-requested state is inconsistent`);
if (item.state === 'observed' && (item.retrievedAt === null || item.expiresAt === null)) throw new TypeError(`enrichment pin ${field} observed state is missing freshness`);
if (item.batches !== undefined) {
if (!Array.isArray(item.batches) || item.batches.length === 0 || item.batches.length > Math.ceil(MAX_CONTAINMENT_ITEMS / MAX_ENRICHMENT_IDS)) throw new TypeError(`enrichment pin ${field} batches are oversized`);
const requestedUnion = new Set<string>(); const resolvedUnion = new Set<string>(); const missingUnion = new Set<string>();
const states: EnrichmentObservationState[] = [];
for (const batch of item.batches) {
if (!batch || typeof batch !== 'object' || Array.isArray(batch) || Object.keys(batch).some((key) => !new Set(['state', 'requestedIds', 'resolvedIds', 'missingIds', 'retrievedAt', 'expiresAt', 'contentDigest']).has(key))) throw new TypeError(`enrichment pin ${field} batch is invalid`);
const candidateBatch = batch as ReferenceEnrichmentBatchPin;
if (!['observed', 'not_requested', 'forbidden', 'unavailable'].includes(candidateBatch.state) || !Array.isArray(candidateBatch.requestedIds) || candidateBatch.requestedIds.length > MAX_ENRICHMENT_IDS || !Array.isArray(candidateBatch.resolvedIds) || !Array.isArray(candidateBatch.missingIds)) throw new TypeError(`enrichment pin ${field} batch shape is invalid`);
const batchRequested = canonicalDecimalIds(candidateBatch.requestedIds, `${field}.batch.requestedIds`);
const batchResolved = canonicalDecimalIds(candidateBatch.resolvedIds, `${field}.batch.resolvedIds`);
const batchMissing = canonicalDecimalIds(candidateBatch.missingIds, `${field}.batch.missingIds`);
if (stableSerialize(batchRequested) !== stableSerialize(candidateBatch.requestedIds) || stableSerialize(batchResolved) !== stableSerialize(candidateBatch.resolvedIds) || stableSerialize(batchMissing) !== stableSerialize(candidateBatch.missingIds) || batchResolved.some((id) => !batchRequested.includes(id)) || batchMissing.some((id) => !batchRequested.includes(id)) || new Set([...batchResolved, ...batchMissing]).size !== batchResolved.length + batchMissing.length) throw new TypeError(`enrichment pin ${field} batch IDs are inconsistent`);
if (candidateBatch.state !== 'observed' && (batchResolved.length > 0 || batchMissing.length > 0)) throw new TypeError(`enrichment pin ${field} batch failure retains body IDs`);
if (typeof candidateBatch.contentDigest !== 'string' || !/^[0-9a-f]{16}$/.test(candidateBatch.contentDigest) || [candidateBatch.retrievedAt, candidateBatch.expiresAt].some((time) => time !== null && (!Number.isSafeInteger(time) || time < 0 || time > 8_640_000_000_000_000)) || (candidateBatch.retrievedAt === null) !== (candidateBatch.expiresAt === null) || (candidateBatch.retrievedAt !== null && candidateBatch.expiresAt !== null && candidateBatch.expiresAt < candidateBatch.retrievedAt)) throw new TypeError(`enrichment pin ${field} batch freshness is invalid`);
states.push(candidateBatch.state); batchRequested.forEach((id) => requestedUnion.add(id)); batchResolved.forEach((id) => resolvedUnion.add(id)); batchMissing.forEach((id) => missingUnion.add(id));
}
const mergedState = states.every((state) => state === 'observed') ? 'observed' : states.includes('forbidden') ? 'forbidden' : states.includes('unavailable') ? 'unavailable' : 'not_requested';
if (mergedState !== item.state || stableSerialize(canonicalDecimalIds([...requestedUnion])) !== stableSerialize(item.requestedIds) || stableSerialize(canonicalDecimalIds([...resolvedUnion])) !== stableSerialize(item.resolvedIds) || stableSerialize(canonicalDecimalIds([...missingUnion])) !== stableSerialize(item.missingIds)) throw new TypeError(`enrichment pin ${field} aggregate batches are inconsistent`);
} else if (item.state !== 'observed' && (item.resolvedIds.length !== 0 || item.missingIds.length !== 0)) throw new TypeError(`enrichment pin ${field} unresolved state has resolved IDs`);
};
validatePart(candidate.names, 'names');
validatePart(candidate.locations, 'locations');
if (candidate.referenceFacts !== undefined) {
if (!Array.isArray(candidate.referenceFacts) || candidate.referenceFacts.length > 3_000 || typeof candidate.referenceFactsDigest !== 'string' || candidate.referenceFactsDigest !== digest(stableSerialize(candidate.referenceFacts))) throw new TypeError('enrichment pin reference facts are invalid');
let previousFact = '';
for (const fact of candidate.referenceFacts) {
if (!fact || typeof fact !== 'object' || Array.isArray(fact) || Object.keys(fact).some((key) => !new Set(['kind', 'id', 'name', 'parentId', 'evidence', 'sourceVerified', 'systemId', 'regionId', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin reference fact is invalid');
const referenceFact = fact as ReferenceLocationFactPin;
assertDecimalId(referenceFact.id, 'enrichment pin reference fact ID');
if (!['station', 'system', 'region'].includes(referenceFact.kind) || (referenceFact.kind === 'station' && !['verified', 'system_listed'].includes(referenceFact.evidence)) || (referenceFact.kind !== 'station' && referenceFact.evidence !== 'verified') || referenceFact.sourceVerified !== (referenceFact.evidence === 'verified') || (referenceFact.name !== null && (typeof referenceFact.name !== 'string' || !referenceFact.name.trim() || referenceFact.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (referenceFact.parentId !== null && !isDecimalId(referenceFact.parentId)) || (referenceFact.systemId !== null && !isDecimalId(referenceFact.systemId)) || (referenceFact.regionId !== null && !isDecimalId(referenceFact.regionId)) || (referenceFact.kind === 'station' && referenceFact.parentId !== referenceFact.systemId) || (referenceFact.kind === 'system' && referenceFact.parentId !== referenceFact.regionId) || (referenceFact.kind === 'region' && (referenceFact.parentId !== null || referenceFact.systemId !== null || referenceFact.regionId !== null)) || referenceFact.referenceVersion !== candidate.referenceVersion) throw new TypeError('enrichment pin reference fact is inconsistent');
const key = `${referenceFact.kind}:${referenceFact.id}`;
if (key <= previousFact) throw new TypeError('enrichment pin reference facts are not canonical');
previousFact = key;
}
} else if (candidate.referenceFactsDigest !== undefined) throw new TypeError('enrichment pin reference facts digest is orphaned');
if (candidate.structures !== undefined) {
if (!Array.isArray(candidate.structures) || candidate.structures.length > 1_000) throw new TypeError('enrichment pin structure observations are oversized');
const seenStructures = new Set<string>();
for (const structure of candidate.structures) {
if (!structure || typeof structure !== 'object' || Array.isArray(structure) || Object.keys(structure).some((key) => !new Set(['structureId', 'characterId', 'status', 'evidence', 'name', 'solarSystemId', 'ownerId', 'typeId', 'observedAt', 'expiresAt', 'referenceVersion']).has(key))) throw new TypeError('enrichment pin structure observation is invalid');
const observation = structure as StructureObservationPin;
assertDecimalId(observation.structureId, 'enrichment pin structure ID');
if (seenStructures.has(observation.structureId) || !Number.isSafeInteger(observation.characterId) || observation.characterId !== authority.characterId || !['accessible', 'forbidden', 'unavailable'].includes(observation.status) || !['esi_structure_access', 'esi_forbidden', 'unavailable'].includes(observation.evidence) || (observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') || (observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') || (observation.status === 'unavailable' && observation.evidence !== 'unavailable') || (observation.status !== 'accessible' && (observation.name !== null || observation.solarSystemId !== null || observation.ownerId !== null || observation.typeId !== null)) || (observation.name !== null && (typeof observation.name !== 'string' || !observation.name.trim() || observation.name.length > MAX_ENRICHMENT_NAME_LENGTH)) || (observation.solarSystemId !== null && !isDecimalId(observation.solarSystemId)) || (observation.ownerId !== null && !isDecimalId(observation.ownerId)) || (observation.typeId !== null && !isDecimalId(observation.typeId)) || typeof observation.referenceVersion !== 'string' || observation.referenceVersion !== candidate.referenceVersion || !Number.isSafeInteger(observation.observedAt) || !Number.isSafeInteger(observation.expiresAt) || observation.observedAt < 0 || observation.expiresAt < observation.observedAt || observation.expiresAt > 8_640_000_000_000_000) throw new TypeError('enrichment pin structure observation is inconsistent');
seenStructures.add(observation.structureId);
}
}
}
/** Stable JSON for pin hashes; object insertion order and caller ID order never affect it. */
export function stableSerialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}`;
}
function digest(value: string): string {
// Two independent 32-bit FNV passes avoid a Node-only dependency in this domain module.
let first = 2166136261;
let second = 2166136261 ^ 0x9e3779b9;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 16777619);
second = Math.imul(second ^ (code + index), 2246822519);
}
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
}
export function deterministicEnrichmentVersion(pin: ReferenceEnrichmentPin, algorithmVersion = 'containment-v1'): string {
return `${algorithmVersion}-${digest(stableSerialize(pin))}`;
}
export function nameStatusFor(id: DecimalId, observation: NamesObservation | undefined, now: number, requested = true): EnrichmentNameStatus {
if (!requested || !observation) return 'not_requested';
const state = observationState(observation.state);
if (state === 'not_requested') return 'not_requested';
if (state === 'forbidden') return 'forbidden';
if (state === 'unavailable') return 'unavailable';
if (now < observation.retrievedAt) return 'unavailable';
if (now >= observation.expiresAt) return 'expired';
if (observation.names.some((entry) => entry.itemId === id)) return 'resolved';
if (observation.missing.includes(id)) return 'missing';
return 'unavailable';
}
export function locationStatusFor(id: DecimalId, observation: LocationsObservation | undefined, now: number, requested = true): EnrichmentLocationStatus {
if (!requested || !observation) return 'not_requested';
const state = observationState(observation.state);
if (state === 'not_requested') return 'not_requested';
if (state === 'forbidden') return 'forbidden';
if (state === 'unavailable') return 'unavailable';
if (now < observation.retrievedAt) return 'unavailable';
if (now >= observation.expiresAt) return 'expired';
if (observation.locations.some((entry) => entry.itemId === id)) return 'resolved';
if (observation.missing.includes(id)) return 'missing';
return 'unavailable';
}
+447 -8
View File
@@ -1,11 +1,29 @@
/** Domain values are JSON-safe. EVE item/location IDs are decimal strings, not JS floats. */
export type UserId = string;
export type CharacterDatasetStatus = {
dataset: string;
status: 'never' | 'queued' | 'syncing' | 'ready' | 'partial' | 'stale' | 'blocked' | 'error' | 'quarantined';
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
generation?: string;
syncedAt?: number;
invalidatedAt?: number;
error?: string;
};
export type Character = {
id: number;
userId: UserId;
name: string;
scopes: string[];
assets: { status: 'never' | 'syncing' | 'ready' | 'error'; syncedAt?: number; expiresAt?: number; error?: string };
/** Exactly one active primary may authenticate while signed out. */
loginRole: 'primary' | 'data_only';
authorizationStatus: 'active' | 'revocation_pending' | 'quarantined' | 'revoked' | 'transferred' | 'reconnect_required';
grantRevision: number;
credentialVersion: number;
authorizationEpoch: number;
datasets: CharacterDatasetStatus[];
assets: { status: 'never' | 'queued' | 'syncing' | 'ready' | 'error'; syncedAt?: number; expiresAt?: number; error?: string };
};
export type Asset = {
itemId: string;
@@ -15,9 +33,39 @@ export type Asset = {
locationId: string;
locationType: string;
locationFlag: string;
/** ESI is_singleton; absent on legacy rows and therefore unknown. */
isSingleton?: boolean;
isBlueprintCopy?: boolean;
};
export type InventoryType = { id: number; name: string; categoryId: number; volume?: number };
export type InventoryType = {
id: number;
name: string;
categoryId: number;
/** Legacy reference rows may omit group identity until refreshed. */
groupId?: number;
volume?: number;
};
export type {
LocationFact,
LocationReader,
ReferenceCategoryRecord,
ReferenceConstellationRecord,
ReferenceGroupRecord,
ReferenceIdSelection,
ReferenceListOptions,
ReferenceReader,
ReferenceReaderHandle,
ReferenceReaderPort,
ReferenceRegionRecord,
ReferenceRepository,
ReferenceSearchOptions,
ReferenceStationEvidence,
ReferenceStationRecord,
ReferenceStationSelection,
ReferenceSystemRecord,
ReferenceSystemSelection,
ReferenceTypeRecord,
} from './reference';
/** Integer cents (0.01 ISK), serialized as decimal text. No floating-point accumulation. */
export type Isk = string;
export type ValuationMethod = 'reference_estimate' | 'immediate_liquidation' | 'expected_sell_order' | 'replacement';
@@ -32,15 +80,11 @@ export interface CharacterAssetRepository {
characters(userId: UserId): Promise<Character[]>;
assets(userId: UserId, characterId: number): Promise<Asset[]>;
}
export interface ReferenceRepository {
types(ids: number[]): Promise<InventoryType[]>;
version(): Promise<string>;
}
export interface MarketEstimates {
estimates(typeIds: number[]): Promise<PriceSnapshot>;
}
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data';
export type Capability = 'asset_search' | 'asset_valuation' | 'manufacturing_opportunities';
export type Requirement = 'assets' | 'blueprints' | 'skills' | 'market_data' | 'wallet_balance' | 'reference_data';
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 =
@@ -56,6 +100,8 @@ export type AssetReport = {
referenceVersion: string;
prices: Omit<PriceSnapshot, 'prices'>;
warnings: string[];
/** Added for pinned downstream reports; optional for legacy asset consumers. */
provenance?: ReportProvenance;
};
export type AssetOperation =
| Exclude<CapabilityGate, { status: 'available' }>
@@ -92,3 +138,396 @@ export interface CapitalRouter {
export interface OrderBook {
buyOrders(typeId: number, locationId: string): Promise<{ orderId: string; price: Isk; remaining: number; minimumVolume: number; range: string }[]>;
}
/**
* Cross-feature contracts for dataset authorization and publication. These are
* data-only ports: persistence, OAuth callbacks, workers and schema migrations
* deliberately remain in their owning milestones.
*/
export type DecimalId = string;
export type DatasetKind =
| 'assets'
| 'wallet_balance'
| 'wallet_journal'
| 'wallet_transactions'
| 'market_data'
| 'reference_data'
| 'blueprints'
| 'skills';
/** These are semantically distinct counters even when their numeric values coincide. */
export type GrantRevision = number;
export type AuthorizationRevision = GrantRevision;
export type AuthorizationEpoch = number;
export type CredentialVersion = number;
export type SnapshotGeneration = string;
export type AppendCheckpoint = string;
export type ReferenceVersion = string;
export type PriceVersion = string;
/** A principal is an authorization subject, never a browser-provided selector. */
export type ResourcePrincipal =
| { kind: 'user'; id: UserId }
| { kind: 'character'; id: DecimalId; ownerUserId: UserId }
| { kind: 'corporation'; id: DecimalId; ownerUserId: UserId }
| { kind: 'system'; id: string };
export type SelectedResource = {
principal: ResourcePrincipal;
requiredScopes: readonly string[];
authorizationRevision: AuthorizationRevision;
authorizationEpoch: AuthorizationEpoch;
};
/** Snapshot and append datasets cannot be represented by one freshness field. */
export type AppendCoverageGap = {
from: AppendCheckpoint | null;
to: AppendCheckpoint | null;
reason: string;
};
export type DatasetCoverage =
| { kind: 'snapshot'; generation: SnapshotGeneration; complete: true }
| { kind: 'snapshot'; generation: null; complete: false }
| {
kind: 'append';
checkpoint: AppendCheckpoint | null;
sourceIdentity: string;
windowStart: number | null;
windowEnd: number | null;
gaps: readonly AppendCoverageGap[];
complete: boolean;
};
export type DatasetSyncStatus =
| 'never'
| 'queued'
| 'syncing'
| 'ready'
| 'partial'
| 'stale'
| 'blocked'
| 'error'
| 'quarantined';
/** A ready state cannot claim incomplete or missing coverage. */
export type CompleteDatasetCoverage =
| { kind: 'snapshot'; generation: SnapshotGeneration; complete: true }
| { kind: 'append'; checkpoint: AppendCheckpoint; sourceIdentity: string; windowStart: number; windowEnd: number; gaps: readonly []; complete: true };
/** Upstream effective time is null when ESI does not provide one. */
type DatasetSyncStateBase = {
principal: ResourcePrincipal;
dataset: DatasetKind;
/** Current authority snapshot; ready data from another revision/epoch is not readable. */
authorizationStatus: AuthorizationStatus;
authorizationEpoch: AuthorizationEpoch;
grantRevision: GrantRevision;
credentialVersion: CredentialVersion | null;
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
failure?: { category: string; retryable: boolean };
};
export type DatasetSyncState =
| (DatasetSyncStateBase & { status: 'ready'; coverage: CompleteDatasetCoverage })
| (DatasetSyncStateBase & { status: Exclude<DatasetSyncStatus, 'ready'>; coverage: DatasetCoverage });
export type AuthorizationStatus = 'active' | 'revocation_pending' | 'quarantined' | 'revoked' | 'transferred';
/**
* A missing upstream revocation/transfer observation is intentionally not
* collapsed into a chosen stale-authority grace period. Operators must approve
* that window before a worker can promise one.
*/
export type StaleAuthorityObservation = {
state: 'current' | 'stale' | 'unknown';
observedAt: number | null;
maximumWindowMs: number | null;
};
export type ResourceAuthorization = {
principal: ResourcePrincipal;
userId: UserId;
status: AuthorizationStatus;
grantRevision: GrantRevision;
credentialVersion: CredentialVersion | null;
grantedScopes: readonly string[];
observedAt: number;
staleAuthority: StaleAuthorityObservation;
authorizationEpoch: AuthorizationEpoch;
};
export type ConsentIntent = 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect';
/** Scopes and revisions are keyed to one selected resource, never one global user list. */
export type ConsentResourceRequest = {
resource: SelectedResource;
requestedScopes: readonly string[];
approvedExistingScopes: readonly string[];
};
export type ConsentResourceGrant = {
/** Echoes the exact bound request; the callback cannot return an unbound principal. */
request: ConsentResourceRequest;
grantedScopes: readonly string[];
grantRevision: GrantRevision;
credentialVersion: CredentialVersion;
authorizationEpoch: AuthorizationEpoch;
};
/** One OAuth/session binding is consumed at most once and carries its scope intent. */
export type ConsentBinding = {
bindingId: string;
sessionId: string;
userId: UserId | null;
intent: ConsentIntent;
capability: Capability;
resourceRequests: readonly [ConsentResourceRequest, ...ConsentResourceRequest[]];
createdAt: number;
expiresAt: number;
state: 'pending' | 'consumed' | 'failed' | 'expired';
};
export type ConsentCallbackResult =
| { status: 'accepted'; bindingId: string; resourceGrants: readonly [ConsentResourceGrant, ...ConsentResourceGrant[]] }
| { status: 'denied'; bindingId: string; resourceGrants: readonly [] }
| { status: 'recovery_required'; bindingId: string; codeOutcome: 'unknown' | 'consumed'; retry: 'recover' | 'new_binding' };
/** Refresh CAS and OAuth replay are separate concerns; unknown rotation is actionable. */
export type CredentialRefreshResult =
| { status: 'updated'; credentialVersion: CredentialVersion; grantRevision: GrantRevision }
| { status: 'unchanged'; credentialVersion: CredentialVersion; grantRevision: GrantRevision }
| { status: 'uncertain'; credentialVersion: CredentialVersion; grantRevision: GrantRevision; action: 'reauthorize' | 'recover' };
export type EntityResolution<T> =
| { status: 'resolved'; value: T }
| { status: 'ambiguous'; candidates: readonly T[] }
| { status: 'unknown'; reason: string };
export type JobOwner =
| { kind: 'user'; userId: UserId }
| { kind: 'system'; service: 'reference' | 'public_feed' | 'maintenance' }
| { kind: 'corporation'; principal: Extract<ResourcePrincipal, { kind: 'corporation' }> };
export type JobAuthority = 'user' | 'system';
export type SyncJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined';
export type SyncJobDataset = 'assets';
export type SafeSyncJob = {
id: string;
dataset: SyncJobDataset;
characterId: number;
status: SyncJobStatus;
attempts: number;
dueAt: number;
createdAt: number;
updatedAt: number;
leaseExpiresAt: number | null;
cancelRequested: boolean;
error?: string;
};
export type PublicationOutcome = 'success' | 'failure';
export type PublicationResourceFence = SelectedResource & {
/** Monotonic revocation/quarantine epoch, distinct from grant and credential versions. */
credentialVersion: CredentialVersion | null;
};
/** A publication fence binds all selected resources and both write outcomes. */
type NonEmptyReadonly<T> = readonly [T, ...T[]];
type PublicationFenceBase = {
jobId: string;
/** Job principal is retained for routing; resources are the authorization fence. */
principal: ResourcePrincipal;
resources: readonly PublicationResourceFence[];
dataset: DatasetKind;
};
export type PublicationFence =
| (PublicationFenceBase & {
coverageKind: 'snapshot';
generation: SnapshotGeneration;
checkpoint: null;
})
| (PublicationFenceBase & {
coverageKind: 'append';
generation: null;
checkpoint: AppendCheckpoint | null;
});
type UserPublicationPrincipal = Exclude<ResourcePrincipal, { kind: 'system' }>;
export type UserPublicationFence = PublicationFence & { principal: UserPublicationPrincipal; resources: NonEmptyReadonly<PublicationResourceFence> };
export type SystemPublicationFence = PublicationFence & { principal: Extract<ResourcePrincipal, { kind: 'system' }>; resources: readonly [] };
type DatasetJobBase = {
id: string;
dataset: DatasetKind;
status: SyncJobStatus;
attempts: number;
dueAt: number;
leaseExpiresAt: number | null;
};
/** Authority is discriminated with ownership; callers cannot promote a job by changing a flag. */
export type DatasetJob = DatasetJobBase &
(
| { owner: Extract<JobOwner, { kind: 'user' }>; authority: 'user'; publicationFence: UserPublicationFence }
| { owner: Extract<JobOwner, { kind: 'corporation' }>; authority: 'user'; publicationFence: UserPublicationFence }
| { owner: Extract<JobOwner, { kind: 'system' }>; authority: 'system'; publicationFence: SystemPublicationFence }
);
export type PublicationAttempt = {
fence: PublicationFence;
outcome: PublicationOutcome;
observedAt: number;
accepted: boolean;
rejectionReason?: 'stale_generation' | 'stale_grant' | 'stale_credential' | 'stale_authorization_epoch' | 'authorization_revoked' | 'quarantined';
};
export type ReportDatasetPin = {
/** The selected resource this dataset pin belongs to; principal is retained as a compatibility index. */
selectedResource: SelectedResource;
principal: ResourcePrincipal;
dataset: DatasetKind;
coverage: DatasetCoverage;
authorizationRevision: AuthorizationRevision;
authorizationEpoch: AuthorizationEpoch;
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
};
export type ReportInputPin = {
selectedResources: readonly SelectedResource[];
requiredDatasetKinds: readonly [DatasetKind, ...DatasetKind[]];
/** One pin per selected resource/dataset; maps below are compatibility indexes. */
datasetPins: readonly ReportDatasetPin[];
authorizationRevisions: Readonly<Record<string, AuthorizationRevision>>;
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
snapshotGenerations: Readonly<Record<string, SnapshotGeneration>>;
appendCheckpoints: Readonly<Record<string, AppendCheckpoint>>;
referenceVersion: ReferenceVersion | null;
priceVersion: PriceVersion | null;
};
export type ReportProvenance = {
reportId: string;
generatedAt: number;
fetchedAt: Readonly<Record<string, number | null>>;
upstreamEffectiveAt: Readonly<Record<string, number | null>>;
input: ReportInputPin;
sourceSkew: boolean;
assumptions: readonly string[];
};
export type CompleteReportDatasetPin = ReportDatasetPin & { coverage: CompleteDatasetCoverage };
export type CompleteReportInputPin = Omit<ReportInputPin, 'datasetPins'> & {
/** Set identity is validated against selectedResources × requiredDatasetKinds. */
pinSet: 'exact_selected_resource_dataset_set';
datasetPins: readonly [CompleteReportDatasetPin, ...CompleteReportDatasetPin[]];
};
export type CompleteReportProvenance = Omit<ReportProvenance, 'input'> & { input: CompleteReportInputPin };
export type InventoryBlueprintState = 'all' | 'original' | 'copy' | 'unknown';
export type InventorySortField = 'character' | 'name' | 'quantity' | 'location' | 'estimated_value';
export type InventorySortDirection = 'ascending' | 'descending';
export type InventoryFilters = {
characterIds?: readonly DecimalId[];
typeIds?: readonly number[];
customName?: string;
categoryIds?: readonly number[];
groupIds?: readonly number[];
minQuantity?: string;
maxQuantity?: string;
locationIds?: readonly DecimalId[];
systemIds?: readonly number[];
regionIds?: readonly number[];
blueprintState?: InventoryBlueprintState;
};
/** Item ID is the canonical unique tie-breaker for every inventory ordering. */
export type InventorySort = { field: InventorySortField; direction: InventorySortDirection; tieBreaker: 'item_id' };
/** Cursor tokens are opaque; the unsigned pin is retained server-side and revalidated on use. */
export type InventoryCursorPin = {
callerUserId: UserId;
queryFingerprint: string;
selectedResources: readonly SelectedResource[];
authorizationRevisions: Readonly<Record<string, AuthorizationRevision>>;
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
snapshotGenerations: Readonly<Record<string, SnapshotGeneration>>;
referenceVersion: ReferenceVersion | null;
priceVersion: PriceVersion | null;
expiresAt: number;
};
export type InventoryCursorPosition = { offset: number; lastItemId: DecimalId | null };
export type InventoryCursor = { token: string };
export type InventoryCursorState = { token: string; pin: InventoryCursorPin; status: 'active' | 'exhausted'; position: InventoryCursorPosition };
/** Server-resolved values supplied on every continuation lookup; no caller signature is required. */
export type InventoryCursorLookup = {
token: string;
callerUserId: UserId;
queryFingerprint: string;
selectedResources: readonly SelectedResource[];
authorizationRevisions: Readonly<Record<string, AuthorizationRevision>>;
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
snapshotGenerations: Readonly<Record<string, SnapshotGeneration>>;
referenceVersion: ReferenceVersion | null;
priceVersion: PriceVersion | null;
now: number;
};
export type InventoryQuery = {
/** Resolved from the verified session; never accepted as caller authority. */
callerUserId: UserId;
/** Resolved by the authorization service from caller-owned resources. */
selectedResources: readonly SelectedResource[];
authorizationRevisions: Readonly<Record<string, AuthorizationRevision>>;
authorizationEpochs: Readonly<Record<string, AuthorizationEpoch>>;
filters: InventoryFilters;
sort: InventorySort;
pageSize: number;
cursor: InventoryCursor | null;
};
export type InventoryRow = Omit<Asset, 'isSingleton'> & {
name: string;
categoryId: number | null;
groupId: number | null;
customName: string | null;
systemId: number | null;
regionId: number | null;
isSingleton: boolean | null;
estimatedValue: Isk | null;
};
export type InventorySummary = {
matchingRows: number;
matchingQuantity: string;
estimatedValue: Isk | null;
scope: 'all_matching_rows';
};
export type InventoryPageState = 'initial' | 'continuation' | 'after_end';
export type InventoryQueryResult =
| { status: 'ready'; rows: readonly InventoryRow[]; summary: InventorySummary; nextCursor: InventoryCursor | null; pageState: 'initial' | 'continuation'; provenance: CompleteReportProvenance }
| { status: 'ready'; rows: readonly InventoryRow[]; summary: InventorySummary; nextCursor: null; pageState: 'after_end'; provenance: CompleteReportProvenance }
| { status: 'partial' | 'stale' | 'authorization_required' | 'unavailable'; rows: readonly InventoryRow[]; summary: InventorySummary | null; reason: string; provenance?: ReportProvenance };
export type WalletBalanceComponent = {
component: 'wallet_balance';
principal: ResourcePrincipal;
amount: Isk;
fetchedAt: number;
upstreamEffectiveAt: number | null;
coverage: DatasetCoverage;
authorizationRevision: AuthorizationRevision;
credentialVersion: CredentialVersion | null;
};
export type WalletBalanceResult =
| { status: 'ready'; components: readonly WalletBalanceComponent[]; provenance: CompleteReportProvenance }
| { status: 'partial'; components: readonly WalletBalanceComponent[]; missing: readonly ResourcePrincipal[]; provenance: ReportProvenance }
| { status: 'blocked' | 'unavailable'; reason: string };
/**
* Approved signed-decimal policy contract. This is intentionally a fixed
* policy, not a selectable rounding-mode API. T04 owns the parser; T02 only
* records the boundary and resource limits.
*/
export type SignedMoneyNormalizationPolicy = {
input: 'decimal_text';
scale: 2;
rounding: 'half_away_from_zero';
grammar: 'json_number_ascii_decimal';
maxRawInputCharacters: 8192;
maxCoefficientDigits: 4096;
exponentRange: readonly [-1000, 1000];
centsShiftRange: readonly [-1000, 1000];
rejectWhitespace: true;
canonicalizeNegativeZero: true;
};
+84
View File
@@ -7,6 +7,90 @@
export type IskLike = string | bigint | number;
const INTEGER_TEXT = /^-?\d+$/;
// The final lookahead makes the end assertion strict. This intentionally
// tightens the old ESI regex, because JavaScript's `$` alone also accepts a
// match immediately before one trailing newline.
const DECIMAL_TEXT = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$(?![\s\S])/;
/** Resource bounds for signed decimal ingestion. These reject excess input; no truncation is performed. */
export const MAX_DECIMAL_INPUT_LENGTH = 8_192;
export const MAX_DECIMAL_COEFFICIENT_DIGITS = 4_096;
export const MAX_DECIMAL_EXPONENT = 1_000n;
export const MAX_DECIMAL_SHIFT = 1_000n;
/** Parse a bounded exponent without constructing a large bigint from hostile text. */
function parseDecimalExponent(value: string): bigint {
let sign = 1n;
let digits = value;
if (digits.startsWith('+') || digits.startsWith('-')) {
if (digits.startsWith('-')) sign = -1n;
digits = digits.slice(1);
}
const normalized = digits.replace(/^0+/, '') || '0';
if (normalized.length > 4 || (normalized.length === 4 && normalized > '1000')) {
throw new RangeError('ISK decimal exponent is outside bounds');
}
return sign * BigInt(normalized);
}
/**
* Convert signed decimal ISK text to integer hundredths without Number
* coercion. The non-negative subset matches the inherited ESI grammar and
* adds one leading minus sign for balances/debits. Values with more than two
* fractional digits use half-away-from-zero rounding; negative zero
* canonicalizes to `0`.
*
* Exponent, derived decimal shift, coefficient digit count, and raw input
* length are bounded explicitly. Resource-excess inputs are rejected rather
* than truncated; these bounds are the intentional compatibility difference
* for oversized source spellings. ESI market adapters separately reject
* every negative spelling.
*/
export function decimalIskToCents(value: string): string {
if (typeof value !== 'string') {
throw new TypeError('ISK decimals must be signed finite decimal text');
}
if (value.length > MAX_DECIMAL_INPUT_LENGTH) {
throw new RangeError('ISK decimal input exceeds resource bounds');
}
if (!DECIMAL_TEXT.test(value)) {
throw new TypeError('ISK decimals must be signed finite decimal text');
}
const exponentIndex = value.search(/[eE]/);
const coefficient = exponentIndex < 0 ? value : value.slice(0, exponentIndex);
const exponentText = exponentIndex < 0 ? undefined : value.slice(exponentIndex + 1);
const negative = coefficient.startsWith('-');
const unsignedCoefficient = negative ? coefficient.slice(1) : coefficient;
const [whole, fraction = ''] = unsignedCoefficient.split('.');
if (whole.length + fraction.length > MAX_DECIMAL_COEFFICIENT_DIGITS) {
throw new RangeError('ISK decimal coefficient exceeds resource bounds');
}
const exponent = exponentText === undefined ? 0n : parseDecimalExponent(exponentText);
// Convert the decimal coefficient to cents before applying the resource
// bound: value * 10^(exponent - fractionDigits) * 100.
const centsShift = exponent + 2n - BigInt(fraction.length);
if (centsShift < -MAX_DECIMAL_SHIFT || centsShift > MAX_DECIMAL_SHIFT) {
throw new RangeError('ISK decimal shift exceeds resource bounds');
}
const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, '');
const significand = BigInt(digits);
let magnitude: bigint;
if (centsShift >= 0n) {
magnitude = significand * 10n ** centsShift;
} else {
const divisor = 10n ** -centsShift;
const quotient = significand / divisor;
const remainder = significand % divisor;
// Half-away-from-zero is half-up for this non-negative magnitude.
magnitude = quotient + (remainder * 2n >= divisor ? 1n : 0n);
}
return negative && magnitude !== 0n ? `-${magnitude.toString(10)}` : magnitude.toString(10);
}
/** Parse a serialized integer-cent amount. */
export function parseIsk(value: unknown): bigint {
+74
View File
@@ -0,0 +1,74 @@
import type { DecimalId, InventoryType, ReferenceVersion } from './model';
/** Narrow compatibility boundary retained for existing asset consumers. */
export interface ReferenceRepository {
types(ids: number[]): Promise<InventoryType[]>;
version(): Promise<string>;
}
export type ReferenceCategoryRecord = { id: number; name: string; published?: boolean };
export type ReferenceGroupRecord = { id: number; categoryId: number; name: string; published?: boolean };
export type ReferenceTypeRecord = { id: number; name: string; categoryId: number; groupId: number; published?: boolean; volume?: number };
export type ReferenceRegionRecord = { id: number; name: string | null };
export type ReferenceConstellationRecord = { id: number; name: string | null; regionId: number | null };
export type ReferenceSystemRecord = {
id: number; name: string; constellationId: number; regionId: number | null;
x: number; y: number; z: number; securityStatus: number; securityClass: string | null;
};
export type ReferenceStationEvidence = 'verified' | 'system_listed';
export type ReferenceStationRecord = {
id: number; systemId: number; systemName: string | null; regionId: number | null;
name: string | null; typeId: number | null; ownerId: number | null;
/** Evidence distinguishes authoritative station rows from system-list-only IDs. */
evidence: ReferenceStationEvidence;
/** Compatibility projection; true exactly when evidence is `verified`. */
sourceVerified: boolean;
x: number | null; y: number | null; z: number | null;
};
/** T03-compatible bounded offset search; inventory cursors remain service-level. */
export type ReferenceSearchOptions = { query: string; categoryId?: number; groupId?: number; published?: boolean; limit?: number; offset?: number };
export type ReferenceListOptions = { ids?: number[]; query?: string; limit?: number; offset?: number };
export type ReferenceIdSelection = number[] | ReferenceListOptions | undefined;
export type ReferenceGroupSelection = number[] | (ReferenceListOptions & { categoryId?: number }) | undefined;
export type ReferenceSystemSelection = number[] | (ReferenceListOptions & { constellationId?: number; regionId?: number }) | undefined;
export type ReferenceStationSelection = number[] | (ReferenceListOptions & { systemId?: number }) | undefined;
/** Structural mirror of T03's indexed reader; records stay immutable/version-pinned. */
export interface ReferenceReader extends ReferenceRepository {
searchTypes(options: ReferenceSearchOptions): Promise<ReferenceTypeRecord[]>;
searchTypesByName(query: string, options?: Omit<ReferenceSearchOptions, 'query'>): Promise<ReferenceTypeRecord[]>;
categories(selection?: ReferenceIdSelection): Promise<ReferenceCategoryRecord[]>;
groups(selection?: ReferenceGroupSelection): Promise<ReferenceGroupRecord[]>;
regions(selection?: ReferenceIdSelection): Promise<ReferenceRegionRecord[]>;
constellations(selection?: ReferenceIdSelection): Promise<ReferenceConstellationRecord[]>;
systems(selection?: ReferenceSystemSelection): Promise<ReferenceSystemRecord[]>;
stations(selection?: ReferenceStationSelection): Promise<ReferenceStationRecord[]>;
}
export interface ReferenceReaderHandle extends ReferenceReader {
readonly pinnedVersion: ReferenceVersion;
close(): void;
}
/** Main reader owns a manifest-pinned handle; callers use one handle across calls. */
export interface ReferenceReaderPort extends ReferenceReader {
pin(): ReferenceReaderHandle;
snapshot(): ReferenceReaderHandle;
}
export type LocationFact = {
kind: 'region' | 'system' | 'station' | 'unknown';
id: DecimalId;
name: string | null;
parentId: DecimalId | null;
/** Null means the ID is absent; otherwise it preserves station source evidence. */
sourceVerified: boolean | null;
referenceVersion: ReferenceVersion;
};
/** Narrow service adapter; unknown IDs are explicit and names are never synthesized. */
export interface LocationReader {
location(id: DecimalId): Promise<LocationFact>;
}
+45 -43
View File
@@ -1,12 +1,13 @@
import type { RequestEvent } from '@sveltejs/kit';
import type { Asset, Character, CharacterAssetRepository, MarketEstimates, ReferenceRepository } from '$lib/domain/model';
import type { Asset, Character, CharacterAssetRepository, MarketEstimates, ReferenceRepository, SafeSyncJob } from '$lib/domain/model';
import { AssetService } from '$lib/domain/assets';
import { configuration, requireLiveConfig } from './config';
import { ConvexStore } from './store';
import { TokenCipher, hashSecret } from './security';
import { EveSso, type VerifiedGrant } from './sso';
import { EveSso } from './sso';
import { EsiClient, EsiMarketEstimates } from './esi';
import { SqliteReferenceRepository } from './reference';
import { capabilityDisabledReason, capabilityEnabled, type ReleaseCapability } from './release-gate';
export const SESSION_COOKIE = 'eve_session';
export type Session = { userId: string; sessionId: string };
@@ -31,7 +32,16 @@ export async function session(event: RequestEvent): Promise<Session | null> {
function demoRepository(): CharacterAssetRepository {
const now = Date.now();
const characters: Character[] = [90000001, 90000002].map((id, index) => ({
id, userId: 'demo', name: `Fixture Pilot ${index + 1}`, scopes: ['esi-assets.read_assets.v1'],
id,
userId: 'demo',
name: `Fixture Pilot ${index + 1}`,
scopes: ['esi-assets.read_assets.v1'],
loginRole: index === 0 ? 'primary' : 'data_only',
authorizationStatus: 'active',
grantRevision: 1,
credentialVersion: 1,
authorizationEpoch: 1,
datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'demo', syncedAt: now }],
assets: { status: 'ready', syncedAt: now, expiresAt: now + 3_600_000 }
}));
const asset = (characterId: number, itemId: string, typeId: number, quantity: number, extra = {}): Asset => ({
@@ -51,7 +61,18 @@ export async function charactersFor(userId: string | null): Promise<Character[]>
return userId ? repository().characters(userId) : [];
}
/** Both conventional browsing and the assistant enter here. A request pins one reference artifact. */
export async function assetReport(userId: string | null) {
export async function assetReport(
userId: string | null,
capability: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'> = 'asset_valuation',
) {
const config = configuration();
// The current shared report includes reference valuation even for the browse
// page. Do not let an asset_search-only rollout accidentally expose it.
const requiredCapabilities: readonly ReleaseCapability[] = capability === 'asset_search'
? ['asset_search', 'asset_valuation']
: ['asset_valuation'];
const disabled = requiredCapabilities.find((required) => !capabilityEnabled(required, config.capabilityAllowlist));
if (disabled) return { status: 'unavailable' as const, reason: capabilityDisabledReason(disabled) };
let reference: SqliteReferenceRepository | undefined;
const getReference = () => {
const config = configuration();
@@ -70,48 +91,29 @@ export async function assetReport(userId: string | null) {
finally { reference?.close(); }
}
const refreshes = new Map<string, Promise<VerifiedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<VerifiedGrant> {
const key = `${userId}:${characterId}`;
const existing = refreshes.get(key); if (existing) return existing;
const promise = (async () => {
const { store, cipher, sso } = live();
const record = await store.tokenRecord(userId, characterId);
if (!record) throw new Error('Character authorization is missing');
const previous = await cipher.open<VerifiedGrant>(record.encryptedTokens, `character:${characterId}`);
if (previous.characterId !== characterId) throw new Error('Character authorization mismatch');
if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous;
const grant = await sso.refresh(previous.refreshToken);
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) throw new Error('Character ownership changed; reconnect required');
const saved = await store.saveTokens(userId, characterId, { encryptedTokens: await cipher.seal(grant, `character:${characterId}`), tokenExpiresAt: grant.expiresAt, scopes: grant.scopes, expectedVersion: record.version });
if (!saved) throw new Error('Authorization changed concurrently; retry synchronization');
return grant;
})();
refreshes.set(key, promise);
try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); }
}
export async function synchronize(userId: string) {
if (configuration().mode === 'demo') return { completed: 2, failed: [] as number[], demo: true };
const { store, esi } = live();
type SyncSkip = { characterId: number; reason: 'authorization_required' | 'queue_full' | 'enqueue_failed' };
export async function synchronize(userId: string, initiatingSessionHash?: string) {
const config = configuration();
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) {
throw new Error(capabilityDisabledReason('asset_sync'));
}
if (config.mode === 'demo') return { queued: 2, jobs: [] as SafeSyncJob[], skipped: [] as SyncSkip[], partial: false, demo: true };
if (!initiatingSessionHash) throw new Error('Authenticated session proof is required');
const { store } = live();
const characters = await store.characters(userId);
const failed: number[] = []; let completed = 0;
const jobs: SafeSyncJob[] = [];
const skipped: SyncSkip[] = [];
for (const character of characters) {
if (!character.scopes.includes('esi-assets.read_assets.v1')) { failed.push(character.id); continue; }
let generation: string | undefined;
if (character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1')) {
skipped.push({ characterId: character.id, reason: 'authorization_required' });
continue;
}
try {
generation = await store.beginSync(userId, character.id);
// Explicit synchronization revalidates revocation/owner continuity even when ESI assets remain cached.
const grant = await characterGrant(userId, character.id, true);
if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing');
const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`);
const chunks = Math.ceil(snapshot.assets.length / 250);
for (let index = 0; index < chunks; index++) await store.stageAssets(userId, character.id, generation, index, snapshot.assets.slice(index * 250, (index + 1) * 250));
await store.finishSync(userId, character.id, generation, chunks, snapshot.syncedAt, snapshot.expiresAt);
completed++;
} catch {
failed.push(character.id);
if (generation) await store.failSync(userId, character.id, generation, 'Synchronization failed. Reconnect if authorization was revoked, or retry after ESI recovers.').catch(() => {});
jobs.push(await store.enqueueAssetSync(userId, character.id, initiatingSessionHash));
} catch (error) {
skipped.push({ characterId: character.id, reason: error instanceof Error && error.message === 'Synchronization queue is full' ? 'queue_full' : 'enqueue_failed' });
}
}
return { completed, failed, demo: false };
return { queued: jobs.length, jobs, skipped, partial: skipped.length > 0, demo: false };
}
+4 -2
View File
@@ -1,12 +1,14 @@
import type { AssetOperation } from '$lib/domain/model';
import type { IntentDecision, IntentRouter } from './intent';
import type { ReleaseCapability } from '../release-gate';
export type AssistantResponse = { status: string; message: string; decision: IntentDecision; operation?: AssetOperation };
/** AI selects a closed-set intent; execution and access checks stay in the shared service. */
export async function answerAssets(input: { userId: string | null; message: string }, router: IntentRouter, report: (userId: string | null) => Promise<AssetOperation>): Promise<AssistantResponse> {
export async function answerAssets(input: { userId: string | null; message: string }, router: IntentRouter, report: (userId: string | null, capability?: Extract<ReleaseCapability, 'asset_search' | 'asset_valuation'>) => Promise<AssetOperation>): Promise<AssistantResponse> {
const decision = await router.classify(input.message);
if (decision.status !== 'routed') return { status: decision.status, message: decision.reason, decision };
if (decision.intent === 'unsupported') return { status: 'unsupported', message: 'This milestone supports browsing all connected-character assets and estimating their total reference value. Item/location filters, liquidation, industry and routes are not implemented. Use /assets or /value.', decision };
const operation = await report(input.userId);
const capability = decision.intent === 'asset_search' ? 'asset_search' : 'asset_valuation';
const operation = await report(input.userId, capability);
const messages = {
ready: 'Here are all connected characters’ assets with their reference-price estimate. This is not an executable sale quote.',
authentication_required: 'Connect an EVE character to continue. Your original request can resume afterward.',
+5
View File
@@ -1,12 +1,17 @@
import { resolve } from 'node:path';
import { parseCapabilityAllowlist, validReleaseId } from './release-gate';
export type AppConfig = ReturnType<typeof configuration>;
export function configuration() {
const mode = process.env.APP_MODE ?? 'live';
if (mode !== 'live' && mode !== 'demo') throw new Error('APP_MODE must be live or demo');
const origin = process.env.APP_ORIGIN ?? 'http://localhost:5173';
if (new URL(origin).origin !== origin) throw new Error('APP_ORIGIN must be an origin without a trailing slash or path');
const releaseId = process.env.APP_RELEASE_ID ?? 'local';
if (!validReleaseId(releaseId)) throw new Error('APP_RELEASE_ID contains invalid release metadata');
return {
mode, origin,
releaseId,
capabilityAllowlist: parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST),
referenceDir: resolve(process.env.REFERENCE_DIR ?? 'data/reference'),
convexUrl: process.env.CONVEX_URL ?? process.env.CONVEX_SELF_HOSTED_URL ?? '',
publicConvexUrl: process.env.PUBLIC_CONVEX_URL ?? '',
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import type { Asset } from '../../domain/model';
import {
DEFAULT_CONTAINMENT_DEPTH,
MAX_CONTAINMENT_ITEMS,
resolveContainment,
type ContainmentInput,
type ContainmentResult,
} from '../../domain/inventory-location';
export { classifyFittedFlag, compareDecimalIds, isDecimalId, resolveContainment } from '../../domain/inventory-location';
export type {
Breadcrumb,
ContainmentInput,
ContainmentNode,
ContainmentResult,
ContainmentRoot,
FittedClassification,
FittedClassificationDetail,
} from '../../domain/inventory-location';
export const CONTAINMENT_MAX_ITEMS = MAX_CONTAINMENT_ITEMS;
export const CONTAINMENT_MAX_DEPTH = DEFAULT_CONTAINMENT_DEPTH;
/**
* Feature-local façade used by the sync worker. The resolver is pure: no
* reference lookups, private names, or valuation are performed here, so a
* staged result can be retried without changing its parent graph.
*/
export class ContainmentResolver {
readonly maxDepth: number;
constructor(maxDepth = DEFAULT_CONTAINMENT_DEPTH) {
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_CONTAINMENT_DEPTH) {
throw new RangeError(`containment depth must be 0..${DEFAULT_CONTAINMENT_DEPTH}`);
}
this.maxDepth = maxDepth;
}
resolve(input: Omit<ContainmentInput, 'maxDepth'>): ContainmentResult {
return resolveContainment({ ...input, maxDepth: this.maxDepth });
}
}
export function resolveContainmentGraph(input: ContainmentInput): ContainmentResult {
return resolveContainment(input);
}
/** Compatibility name for callers that describe this operation as a graph walk. */
export const resolveContainmentTree = resolveContainmentGraph;
export const buildContainmentGraph = resolveContainmentGraph;
export const resolveAssetContainment = resolveContainmentGraph;
/** A helper for sync adapters that only need to stage the source assets. */
export function validateContainmentInput(assets: readonly Asset[], characterId: number): void {
if (!Array.isArray(assets) || assets.length > CONTAINMENT_MAX_ITEMS) throw new RangeError(`containment item cap exceeded (${CONTAINMENT_MAX_ITEMS})`);
if (!Number.isSafeInteger(characterId) || characterId < 0) throw new TypeError('containment character ID is invalid');
// resolveContainment performs the full runtime validation and fails closed;
// calling it here intentionally avoids a second, potentially divergent validator.
resolveContainment({ assets, characterId, maxDepth: 0 });
}
@@ -0,0 +1,400 @@
import type {
ReferenceReader,
ReferenceReaderHandle,
ReferenceReaderPort,
ReferenceStationRecord,
ReferenceSystemRecord,
} from '../../domain/reference';
import type { Asset } from '../../domain/model';
import {
createReferenceEnrichmentPin,
decimalIdToSafeNumber,
compareDecimalIds,
DEFAULT_CONTAINMENT_DEPTH,
deterministicEnrichmentVersion,
isDecimalId,
locationStatusFor,
nameStatusFor,
resolveContainment,
validateAuthorityContext,
type DecimalId,
type EnrichedLocationNode,
type EnrichmentLocationStatus,
type EnrichmentNameStatus,
type EnrichmentObservationState,
type LocationEnrichmentResult,
type LocationReference,
type LocationsObservation,
type NamesObservation,
normalizeObservationBatches,
type ReferenceEnrichmentPin,
type ReferenceLocationFactPin,
type InventoryLocationAuthority,
type LocationUnresolved,
type UnresolvedReason,
} from '../../domain/inventory-location';
import { EsiError, EsiHttpError } from '../esi';
import {
validatePrivateStructureContext,
type PrivateStructureAccessObservation,
type PrivateStructureContext,
type PrivateStructurePort,
} from './private-structure-cache';
export const CHARACTER_ASSETS_SCOPE = 'esi-assets.read_assets.v1' as const;
export const MAX_REFERENCE_ROOTS = 1_000;
export type LocationEnrichmentRequest = {
assets: readonly Asset[];
authority: InventoryLocationAuthority;
/** T07's staged snapshot generation; it is never inferred from asset content. */
assetGeneration: string;
names?: NamesObservation;
namesBatches?: readonly NamesObservation[];
locations?: LocationsObservation;
locationsBatches?: readonly LocationsObservation[];
namesState?: 'not_requested' | 'forbidden' | 'unavailable';
locationsState?: 'not_requested' | 'forbidden' | 'unavailable';
namesRequestedIds?: readonly DecimalId[];
locationsRequestedIds?: readonly DecimalId[];
structureRevision?: string;
typeNames?: ReadonlyMap<number, string> | Readonly<Record<string, string>>;
maxDepth?: number;
};
export type LocationServiceOptions = {
reference: ReferenceReaderPort | ReferenceReaderHandle | ReferenceReader;
structures?: PrivateStructurePort;
now?: () => number;
maxDepth?: number;
};
type PinnedReference = ReferenceReader & { pinnedVersion: string; close?: () => void };
type RootOutcome = {
location: LocationReference;
reasons: readonly UnresolvedReason[];
structureObservation?: PrivateStructureAccessObservation;
};
function finiteNow(clock: () => number): number {
const value = clock();
if (!Number.isFinite(value)) throw new RangeError('location service clock must return a finite timestamp');
return value;
}
function decimalIdForReference(id: DecimalId): number | null { return decimalIdToSafeNumber(id); }
function referenceId(value: number): DecimalId { return String(value); }
function uniqueReasons(reasons: readonly UnresolvedReason[]): UnresolvedReason[] { return [...new Set(reasons)].sort(); }
function nameMap(observation: NamesObservation | undefined, batches: readonly NamesObservation[] = []): Map<DecimalId, string> {
const result = new Map<DecimalId, string>();
for (const source of batches.length ? batches : observation ? [observation] : []) {
for (const entry of source.names) if (isDecimalId(entry.itemId) && typeof entry.name === 'string' && entry.name.trim()) result.set(entry.itemId, entry.name);
}
return result;
}
function positionMap(observation: LocationsObservation | undefined, batches: readonly LocationsObservation[] = []): Map<DecimalId, { x: number; y: number; z: number }> {
const result = new Map<DecimalId, { x: number; y: number; z: number }>();
for (const source of batches.length ? batches : observation ? [observation] : []) {
for (const entry of source.locations) {
if (!isDecimalId(entry.itemId) || !entry.position || !Number.isFinite(entry.position.x) || !Number.isFinite(entry.position.y) || !Number.isFinite(entry.position.z)) continue;
result.set(entry.itemId, { ...entry.position });
}
}
return result;
}
function observationMap<T extends NamesObservation | LocationsObservation>(observations: readonly T[]): Map<DecimalId, T> {
const result = new Map<DecimalId, T>();
for (const observation of observations) for (const id of observation.requestedIds) result.set(id, observation);
return result;
}
function privateContext(authority: InventoryLocationAuthority, referenceVersion: string): PrivateStructureContext {
return validatePrivateStructureContext({ ...validateAuthorityContext(authority), referenceVersion });
}
function helperStatus<T extends EnrichmentNameStatus | EnrichmentLocationStatus>(
observation: NamesObservation | LocationsObservation | undefined,
requested: boolean,
state: EnrichmentObservationState | undefined,
resolver: (id: DecimalId, observation: any, now: number, requested: boolean) => T,
id: DecimalId,
now: number,
): T {
if (observation) return resolver(id, observation, now, requested);
if (!requested || state === 'not_requested') return 'not_requested' as T;
if (state === 'observed') throw new TypeError('observed helper state requires an observation');
return (state ?? 'unavailable') as T;
}
function emptyUnknown(id: DecimalId, version: string): LocationReference {
return { kind: 'unknown', id, name: null, parentId: null, evidence: 'unknown', sourceVerified: null, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
}
function stationReference(station: ReferenceStationRecord, version: string, system: LocationReference | null, region: LocationReference | null): LocationReference {
return {
kind: 'station', id: referenceId(station.id), name: typeof station.name === 'string' ? station.name : null, parentId: referenceId(station.systemId), evidence: station.evidence, sourceVerified: station.sourceVerified,
referenceVersion: version, dockingPermission: 'unknown', system, region,
};
}
function systemReference(system: ReferenceSystemRecord, version: string, region: LocationReference | null): LocationReference {
return {
kind: 'system', id: referenceId(system.id), name: typeof system.name === 'string' ? system.name : null, parentId: system.regionId === null ? null : referenceId(system.regionId), evidence: 'verified', sourceVerified: true,
referenceVersion: version, dockingPermission: 'unknown', system: null, region,
};
}
function regionReference(id: number, name: string | null, version: string): LocationReference {
return { kind: 'region', id: referenceId(id), name, parentId: null, evidence: 'verified', sourceVerified: true, referenceVersion: version, dockingPermission: 'unknown', system: null, region: null };
}
/**
* Resolve one immutable reference handle for an entire enrichment. Callers
* cannot observe a station from one reference artifact and its region from
* another. The returned handle is closed after all reads complete.
*/
function pinReference(reference: LocationServiceOptions['reference']): PinnedReference {
const candidate = reference as Partial<ReferenceReaderPort> & Partial<ReferenceReaderHandle>;
if (typeof candidate.pin === 'function') {
const pinned = candidate.pin() as PinnedReference;
if (!pinned || typeof pinned.pinnedVersion !== 'string' || !pinned.pinnedVersion) {
try { pinned?.close?.(); } catch { /* preserve the pin validation failure */ }
throw new Error('reference pin did not provide a version');
}
return pinned;
}
const version = candidate.pinnedVersion;
if (typeof version !== 'string' || !version) throw new Error('reference reader must provide a pinned version');
return reference as PinnedReference;
}
async function readRegion(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
const rows = await reader.regions({ ids: [id] });
const row = rows.find((candidate) => candidate.id === id);
return row ? regionReference(row.id, row.name, version) : null;
}
async function readSystem(reader: ReferenceReader, id: number, version: string): Promise<{ reference: LocationReference | null; row: ReferenceSystemRecord | null }> {
const rows = await reader.systems({ ids: [id] });
const row = rows.find((candidate) => candidate.id === id);
if (!row) return { reference: null, row: null };
const region = row.regionId === null ? null : await readRegion(reader, row.regionId, version);
return { reference: systemReference(row, version, region), row };
}
async function readStation(reader: ReferenceReader, id: number, version: string): Promise<LocationReference | null> {
const rows = await reader.stations({ ids: [id] });
const station = rows.find((candidate) => candidate.id === id);
if (!station) return null;
const systemResult = await readSystem(reader, station.systemId, version);
const region = systemResult.reference?.region ?? (station.regionId === null ? null : await readRegion(reader, station.regionId, version));
return stationReference(station, version, systemResult.reference, region);
}
async function structureLocation(value: PrivateStructureAccessObservation, version: string, reader: ReferenceReader): Promise<{ location: LocationReference; reasons: UnresolvedReason[] }> {
let system: LocationReference | null = null;
const reasons: UnresolvedReason[] = [];
if (value.status === 'accessible' && value.solarSystemId !== undefined && value.solarSystemId !== null) {
const numericSystemId = decimalIdForReference(value.solarSystemId);
if (numericSystemId === null) reasons.push('unsafe_reference_id');
else {
const result = await readSystem(reader, numericSystemId, version);
system = result.reference;
if (!system || (result.row?.regionId !== null && !system.region)) reasons.push('reference_unknown');
}
}
return {
location: {
kind: 'structure', id: value.structureId, name: typeof value.name === 'string' ? value.name : null, parentId: null,
evidence: value.status === 'accessible' ? 'private_structure_access' : 'unknown',
sourceVerified: value.status === 'accessible' ? true : null,
observedAt: value.observedAt, expiresAt: value.expiresAt,
referenceVersion: version, dockingPermission: 'unknown', system, region: system?.region ?? null,
},
reasons,
};
}
/**
* Bounded location/containment enrichment. It returns a result even when
* portions are unavailable so T07 can stage and publish only when its own
* completeness policy permits. No result includes valuation or quantity
* multiplication; quantity remains the source asset quantity on each row.
*/
export class InventoryLocationService {
private readonly reference: LocationServiceOptions['reference'];
private readonly structures?: PrivateStructurePort;
private readonly now: () => number;
private readonly maxDepth: number | undefined;
constructor(options: LocationServiceOptions) {
this.reference = options.reference;
this.structures = options.structures;
this.now = options.now ?? (() => Date.now());
this.maxDepth = options.maxDepth;
}
async enrich(request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
const authority = validateAuthorityContext(request.authority);
if (!authority.scopes.includes(CHARACTER_ASSETS_SCOPE)) throw new TypeError('character asset scope is required for location enrichment');
if (typeof request.assetGeneration !== 'string' || !request.assetGeneration) throw new TypeError('asset generation is required');
if (!Array.isArray(request.assets)) throw new TypeError('assets must be an array');
for (const asset of request.assets) {
if (!asset || typeof asset !== 'object' || asset.characterId !== authority.characterId) throw new TypeError('asset character or shape is invalid');
}
if (request.names?.characterId !== undefined && request.names.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
if (request.locations?.characterId !== undefined && request.locations.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
for (const observation of request.namesBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('name observation character mismatch');
for (const observation of request.locationsBatches ?? []) if (observation.characterId !== undefined && observation.characterId !== authority.characterId) throw new TypeError('location observation character mismatch');
const now = finiteNow(this.now);
const namesBatches = request.namesBatches ?? (request.names ? [request.names] : []);
const locationsBatches = request.locationsBatches ?? (request.locations ? [request.locations] : []);
const namesPlan = normalizeObservationBatches(namesBatches, 'names', request.namesState, request.namesRequestedIds);
const locationsPlan = normalizeObservationBatches(locationsBatches, 'locations', request.locationsState, request.locationsRequestedIds);
const containmentDepth = request.maxDepth ?? this.maxDepth ?? DEFAULT_CONTAINMENT_DEPTH;
let pinned: PinnedReference | undefined;
try {
pinned = pinReference(this.reference);
const version = pinned.pinnedVersion;
const freshNameBatches = namesBatches.filter((observation) => (observation.state === undefined || observation.state === 'observed') && now >= observation.retrievedAt && now < observation.expiresAt);
const containment = resolveContainment({ assets: request.assets, characterId: authority.characterId, names: freshNameBatches.length > 0 ? nameMap(request.names, freshNameBatches) : undefined, typeNames: request.typeNames, maxDepth: containmentDepth });
const roots = new Map<string, RootOutcome>();
let referenceRootCount = 0;
for (const root of containment.roots) {
if (root.kind !== 'external') continue;
const key = `${root.locationType}:${root.id}`;
if (roots.has(key)) continue;
if (referenceRootCount >= MAX_REFERENCE_ROOTS) {
roots.set(key, { location: emptyUnknown(root.id, version), reasons: ['reference_unavailable'] });
continue;
}
referenceRootCount += 1;
roots.set(key, await this.resolveRoot(root.locationType, root.id, authority, version, pinned, now));
}
const referenceFacts: ReferenceLocationFactPin[] = [];
const seenReferenceFacts = new Set<string>();
const collectReferenceFacts = (location: LocationReference | null): void => {
if (!location || (location.kind !== 'station' && location.kind !== 'system' && location.kind !== 'region')) return;
const key = `${location.kind}:${location.id}`;
if (!seenReferenceFacts.has(key)) {
seenReferenceFacts.add(key);
referenceFacts.push({ kind: location.kind, id: location.id, name: location.name, parentId: location.parentId, evidence: location.evidence as 'verified' | 'system_listed', sourceVerified: location.sourceVerified === true, systemId: location.kind === 'station' ? location.parentId : location.system?.id ?? null, regionId: location.region?.id ?? null, referenceVersion: version });
}
collectReferenceFacts(location.system); collectReferenceFacts(location.region);
};
for (const outcome of roots.values()) collectReferenceFacts(outcome.location);
const structureObservations = [...roots.values()].flatMap((outcome) => outcome.structureObservation ? [{ structureId: outcome.structureObservation.structureId, characterId: outcome.structureObservation.characterId, status: outcome.structureObservation.status, evidence: outcome.structureObservation.evidence, name: outcome.structureObservation.name, solarSystemId: outcome.structureObservation.solarSystemId ?? null, ownerId: outcome.structureObservation.ownerId ?? null, typeId: outcome.structureObservation.typeId ?? null, observedAt: outcome.structureObservation.observedAt, expiresAt: outcome.structureObservation.expiresAt, referenceVersion: version }] : []);
const pin = createReferenceEnrichmentPin({
assetGeneration: request.assetGeneration, authority, referenceVersion: version, names: request.names ?? (namesBatches.length === 1 ? namesBatches[0] : undefined), namesBatches: namesBatches.length > 1 ? namesBatches : undefined, locations: request.locations ?? (locationsBatches.length === 1 ? locationsBatches[0] : undefined), locationsBatches: locationsBatches.length > 1 ? locationsBatches : undefined,
namesState: namesPlan.state, locationsState: locationsPlan.state, namesRequestedIds: namesPlan.requestedIds, locationsRequestedIds: locationsPlan.requestedIds,
structureRevision: request.structureRevision, structureObservations, referenceFacts, containmentDepth,
});
const names = nameMap(request.names, namesBatches);
const positions = positionMap(request.locations, locationsBatches);
const requestedNames = new Set(namesPlan.requestedIds);
const requestedLocations = new Set(locationsPlan.requestedIds);
const nameObservations = observationMap(namesBatches);
const locationObservations = observationMap(locationsBatches);
const unresolved: LocationUnresolved[] = containment.unresolved.map((entry) => ({ itemId: entry.itemId, reasons: [...entry.reasons] }));
const items: EnrichedLocationNode[] = containment.nodes.map((node) => {
const requestedName = requestedNames.has(node.itemId);
const requestedLocation = requestedLocations.has(node.itemId);
const nameObservation = nameObservations.get(node.itemId) ?? request.names;
const locationObservation = locationObservations.get(node.itemId) ?? request.locations;
const nameStatus = helperStatus(nameObservation, requestedName, namesPlan.state, nameStatusFor, node.itemId, now);
const locationStatus = helperStatus(locationObservation, requestedLocation, locationsPlan.state, locationStatusFor, node.itemId, now);
const customName = nameStatus === 'resolved' ? (names.get(node.itemId) ?? null) : null;
const rootOutcome = node.root.kind === 'external' ? roots.get(`${node.root.locationType}:${node.root.id}`) : undefined;
const location = rootOutcome?.location ?? emptyUnknown(node.root.id ?? node.locationId, version);
const reasons = new Set<UnresolvedReason>([...node.unresolved]);
if (nameStatus === 'expired') reasons.add('name_expired');
if (nameStatus === 'forbidden') reasons.add('name_forbidden');
if (nameStatus === 'unavailable') reasons.add('name_unavailable');
if (locationStatus === 'expired') reasons.add('location_expired');
if (locationStatus === 'forbidden') reasons.add('location_forbidden');
if (locationStatus === 'unavailable') reasons.add('location_unavailable');
if (rootOutcome) for (const reason of rootOutcome.reasons) reasons.add(reason);
const mergedReasons = uniqueReasons([...reasons]);
if (mergedReasons.length > 0) unresolved.push({ itemId: node.itemId, reasons: mergedReasons });
return { ...node, unresolved: mergedReasons, customName, nameStatus, position: locationStatus === 'resolved' ? (positions.get(node.itemId) ?? null) : null, locationStatus, location };
});
// A root with no asset cannot make a row, but it is still part of the
// result's pinned location set for deterministic T09 filtering.
const resolvedRoots = [...roots.values()].map((value) => value.location).sort((left, right) => compareDecimalIds(left.id, right.id));
const uniqueUnresolved = new Map<string, { itemId: DecimalId | null; reasons: Set<UnresolvedReason> }>();
for (const entry of unresolved) {
const key = entry.itemId ?? '';
const current = uniqueUnresolved.get(key) ?? { itemId: entry.itemId, reasons: new Set<UnresolvedReason>() };
for (const reason of entry.reasons) current.reasons.add(reason);
uniqueUnresolved.set(key, current);
}
const finalUnresolved = [...uniqueUnresolved.values()].map((entry) => ({ itemId: entry.itemId, reasons: uniqueReasons([...entry.reasons]) })).sort((left, right) => (left.itemId && right.itemId ? compareDecimalIds(left.itemId, right.itemId) : (left.itemId ?? '').localeCompare(right.itemId ?? '')) || left.reasons.join(',').localeCompare(right.reasons.join(',')));
const complete = finalUnresolved.length === 0;
return { status: complete ? 'complete' : 'partial', complete, items, rows: items, roots: resolvedRoots, unresolved: finalUnresolved, inputPin: pin, enrichmentVersion: deterministicEnrichmentVersion(pin) };
} finally {
if (pinned && typeof pinned.close === 'function' && pinned !== this.reference) pinned.close();
}
}
private async resolveRoot(locationType: string, id: DecimalId, authority: InventoryLocationAuthority, version: string, reader: ReferenceReader, now: number): Promise<RootOutcome> {
const numericId = decimalIdForReference(id);
if (locationType === 'other') {
if (!this.structures) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
let observation: PrivateStructureAccessObservation;
try {
observation = await this.structures.access(privateContext(authority, version), id);
} catch (error) {
// Authentication/lease fences remain fatal; malformed optional helper
// responses become an explicit unavailable structure instead.
if (error instanceof EsiError && error instanceof EsiHttpError && error.status === 401) throw error;
if (error instanceof EsiError) return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
throw error;
}
if ((observation.status !== 'accessible' && observation.status !== 'forbidden' && observation.status !== 'unavailable') || observation.structureId !== id || observation.characterId !== authority.characterId || observation.dockingPermission !== 'unknown' ||
(observation.status === 'accessible' && observation.evidence !== 'esi_structure_access') ||
(observation.status === 'forbidden' && observation.evidence !== 'esi_forbidden') ||
(observation.status === 'unavailable' && observation.evidence !== 'unavailable') ||
(observation.status !== 'accessible' && observation.name !== null) ||
!Number.isFinite(observation.observedAt) || !Number.isFinite(observation.expiresAt) || observation.expiresAt <= now || observation.expiresAt < observation.observedAt) {
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
}
try {
if (observation.status === 'accessible') {
const result = await structureLocation(observation, version, reader);
return { ...result, structureObservation: observation };
}
const denied = await structureLocation(observation, version, reader);
return { location: denied.location, reasons: [observation.status === 'forbidden' ? 'forbidden_structure' : 'unavailable_structure'], structureObservation: observation };
} catch (error) {
if (error instanceof EsiError) throw error;
return { location: emptyUnknown(id, version), reasons: ['unavailable_structure'] };
}
}
if (numericId === null) return { location: emptyUnknown(id, version), reasons: ['unsafe_reference_id'] };
try {
if (locationType === 'station') {
const location = await readStation(reader, numericId, version);
if (!location) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
const reasons: UnresolvedReason[] = [];
if (!location.system) reasons.push('reference_unknown');
else if (location.system.parentId !== null && !location.system.region) reasons.push('reference_unknown');
return { location, reasons };
}
if (locationType === 'solar_system') {
const result = await readSystem(reader, numericId, version);
if (!result.reference) return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
const reasons: UnresolvedReason[] = [];
if (result.row?.regionId !== null && !result.reference.region) reasons.push('reference_unknown');
return { location: result.reference, reasons };
}
return { location: emptyUnknown(id, version), reasons: ['reference_unknown'] };
} catch {
return { location: emptyUnknown(id, version), reasons: ['reference_unavailable'] };
}
}
}
export async function enrichInventoryLocations(options: LocationServiceOptions, request: LocationEnrichmentRequest): Promise<LocationEnrichmentResult> {
return new InventoryLocationService(options).enrich(request);
}
export const LocationService = InventoryLocationService;
export const enrichLocations = enrichInventoryLocations;
export const enrichInventoryLocation = enrichInventoryLocations;
export type { ReferenceEnrichmentPin };
@@ -0,0 +1,291 @@
import {
assertDecimalId,
stableSerialize,
validateAuthorityContext,
type DecimalId,
type InventoryLocationAuthority,
} from '../../domain/inventory-location';
import { ESI_STRUCTURES_SCOPE, EsiClient, EsiError, EsiHttpError, EsiRateLimitError, type AuthorizationKey } from '../esi';
export type PrivateStructureContext = InventoryLocationAuthority & { referenceVersion: string };
/**
* This is deliberately an access observation, not an ACL or docking result.
* A successful ESI structure read proves that ESI returned data for this
* character-scoped request; it does not prove docking, tethering, or docking
* permission. The literal `unknown` is therefore part of every variant.
*/
export type PrivateStructureAccessObservation = {
structureId: DecimalId;
characterId: number;
status: 'accessible' | 'forbidden' | 'unavailable';
evidence: 'esi_structure_access' | 'esi_forbidden' | 'unavailable';
name: string | null;
observedAt: number;
expiresAt: number;
revision?: string;
dockingPermission: 'unknown';
reason?: string;
/** Optional facts returned by the trusted structure endpoint. */
solarSystemId?: DecimalId | null;
ownerId?: DecimalId | null;
typeId?: DecimalId | null;
position?: { x: number; y: number; z: number } | null;
};
export type PrivateStructureReader = (context: PrivateStructureContext, structureId: DecimalId) => Promise<PrivateStructureAccessObservation>;
export interface PrivateStructurePort {
access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation>;
}
export type PrivateStructureCacheOptions = {
now?: () => number;
maxEntries?: number;
maxBytes?: number;
};
const DEFAULT_MAX_ENTRIES = 128;
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
const MAX_NAME_LENGTH = 512;
const MAX_REASON_LENGTH = 256;
function nowOf(clock: () => number): number {
const value = clock();
if (!Number.isFinite(value)) throw new RangeError('cache clock must return a finite timestamp');
return value;
}
function validCounter(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
export function validatePrivateStructureContext(value: unknown): PrivateStructureContext {
if (!value || typeof value !== 'object') throw new TypeError('private structure context is required');
const candidate = value as Partial<PrivateStructureContext>;
const authority = validateAuthorityContext(candidate);
if (typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion) throw new TypeError('reference version is required for private structure access');
return { ...authority, referenceVersion: candidate.referenceVersion };
}
export function privateStructureCacheKey(context: PrivateStructureContext, structureId: DecimalId): string {
const valid = validatePrivateStructureContext(context);
assertDecimalId(structureId, 'structure ID');
return stableSerialize({
userId: valid.userId,
principal: valid.principal,
characterId: valid.characterId,
scopes: [...valid.scopes].sort(),
grantRevision: valid.grantRevision,
authorizationEpoch: valid.authorizationEpoch,
credentialVersion: valid.credentialVersion,
referenceVersion: valid.referenceVersion,
structureId,
});
}
function validateObservation(value: unknown, context: PrivateStructureContext, structureId: DecimalId, now: number): PrivateStructureAccessObservation {
if (!value || typeof value !== 'object') throw new TypeError('private structure reader returned invalid data');
const candidate = value as Partial<PrivateStructureAccessObservation>;
assertDecimalId(candidate.structureId, 'structure observation ID');
if (candidate.structureId !== structureId) throw new TypeError('private structure observation ID does not match request');
if (candidate.characterId !== context.characterId) throw new TypeError('private structure observation character mismatch');
if (candidate.status !== 'accessible' && candidate.status !== 'forbidden' && candidate.status !== 'unavailable') throw new TypeError('private structure observation status is invalid');
if (candidate.evidence !== 'esi_structure_access' && candidate.evidence !== 'esi_forbidden' && candidate.evidence !== 'unavailable') throw new TypeError('private structure observation evidence is invalid');
if (candidate.status === 'accessible' && candidate.evidence !== 'esi_structure_access') throw new TypeError('accessible structure observation has invalid evidence');
if (candidate.status === 'forbidden' && candidate.evidence !== 'esi_forbidden') throw new TypeError('forbidden structure observation has invalid evidence');
if (candidate.status === 'unavailable' && candidate.evidence !== 'unavailable') throw new TypeError('unavailable structure observation has invalid evidence');
if (candidate.dockingPermission !== 'unknown') throw new TypeError('structure access cannot assert docking permission');
if (typeof candidate.name !== 'string' && candidate.name !== null) throw new TypeError('private structure name is invalid');
if (typeof candidate.name === 'string' && (candidate.name.length > MAX_NAME_LENGTH || !candidate.name.trim())) throw new TypeError('private structure name is invalid');
if (candidate.status !== 'accessible' && candidate.name !== null) throw new TypeError('private structure denial cannot carry a private name');
const observedAt = candidate.observedAt;
const expiresAt = candidate.expiresAt;
if (typeof observedAt !== 'number' || typeof expiresAt !== 'number' || !Number.isFinite(observedAt) || !Number.isFinite(expiresAt) || expiresAt < observedAt) throw new TypeError('private structure freshness is invalid');
if (expiresAt <= now) throw new TypeError('expired private structure observations are not cacheable');
if (candidate.reason !== undefined && (typeof candidate.reason !== 'string' || candidate.reason.length > MAX_REASON_LENGTH)) throw new TypeError('private structure reason is invalid');
if (candidate.revision !== undefined && (typeof candidate.revision !== 'string' || !candidate.revision)) throw new TypeError('private structure revision is invalid');
for (const [field, value] of [['solarSystemId', candidate.solarSystemId], ['ownerId', candidate.ownerId], ['typeId', candidate.typeId] ] as const) {
if (value !== undefined && value !== null) assertDecimalId(value, field);
}
if (candidate.position !== undefined && candidate.position !== null) {
if (typeof candidate.position !== 'object' || !Number.isFinite(candidate.position.x) || !Number.isFinite(candidate.position.y) || !Number.isFinite(candidate.position.z)) throw new TypeError('private structure position is invalid');
}
if (candidate.status !== 'accessible' && (candidate.solarSystemId !== undefined || candidate.ownerId !== undefined || candidate.typeId !== undefined || candidate.position !== undefined)) throw new TypeError('private structure denial cannot carry private facts');
return {
structureId,
characterId: context.characterId,
status: candidate.status,
evidence: candidate.evidence,
name: candidate.name ?? null,
observedAt,
expiresAt,
...(candidate.revision === undefined ? {} : { revision: candidate.revision }),
dockingPermission: 'unknown',
...(candidate.reason === undefined ? {} : { reason: candidate.reason }),
...(candidate.solarSystemId === undefined ? {} : { solarSystemId: candidate.solarSystemId }),
...(candidate.ownerId === undefined ? {} : { ownerId: candidate.ownerId }),
...(candidate.typeId === undefined ? {} : { typeId: candidate.typeId }),
...(candidate.position === undefined ? {} : { position: candidate.position === null ? null : { ...candidate.position } }),
};
}
type Entry = { key: string; value: PrivateStructureAccessObservation; bytes: number; touchedAt: number };
/** Bounded, authority-isolated process cache for private structure observations. */
export class PrivateStructureCache {
private readonly entries = new Map<string, Entry>();
private bytes = 0;
private readonly now: () => number;
private readonly maxEntries: number;
private readonly maxBytes: number;
constructor(options: PrivateStructureCacheOptions = {}) {
this.now = options.now ?? (() => Date.now());
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1 || this.maxEntries > DEFAULT_MAX_ENTRIES) throw new RangeError('private structure cache entry cap is invalid');
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1024 || this.maxBytes > DEFAULT_MAX_BYTES) throw new RangeError('private structure cache byte cap is invalid');
}
get size(): number { return this.entries.size; }
get byteSize(): number { return this.bytes; }
stats(): { entries: number; bytes: number; maxEntries: number; maxBytes: number } { return { entries: this.entries.size, bytes: this.bytes, maxEntries: this.maxEntries, maxBytes: this.maxBytes }; }
get(context: PrivateStructureContext, structureId: DecimalId): PrivateStructureAccessObservation | null {
const key = privateStructureCacheKey(context, structureId);
const entry = this.entries.get(key);
if (!entry) return null;
const now = nowOf(this.now);
if (entry.value.expiresAt <= now) { this.delete(key); return null; }
// Touching an entry makes eviction LRU while retaining deterministic order.
this.entries.delete(key);
entry.touchedAt = now;
this.entries.set(key, entry);
return { ...entry.value };
}
set(context: PrivateStructureContext, structureId: DecimalId, value: PrivateStructureAccessObservation): boolean {
const key = privateStructureCacheKey(context, structureId);
const now = nowOf(this.now);
const checked = validateObservation(value, validatePrivateStructureContext(context), structureId, now);
const bytes = new TextEncoder().encode(JSON.stringify({ key, value: checked })).byteLength;
if (bytes > this.maxBytes) return false;
this.delete(key);
this.entries.set(key, { key, value: checked, bytes, touchedAt: now });
this.bytes += bytes;
this.evict();
return this.entries.has(key);
}
async getOrLoad(context: PrivateStructureContext, structureId: DecimalId, reader: PrivateStructureReader): Promise<PrivateStructureAccessObservation> {
const hit = this.get(context, structureId);
if (hit) return hit;
const validContext = validatePrivateStructureContext(context);
const value = await reader(validContext, structureId);
const checked = validateObservation(value, validContext, structureId, nowOf(this.now));
this.set(validContext, structureId, checked);
return { ...checked };
}
/** Remove one authority key. A revision change can use invalidateAuthority. */
invalidate(context: PrivateStructureContext, structureId?: DecimalId): number {
const valid = validatePrivateStructureContext(context);
if (structureId !== undefined) {
const key = privateStructureCacheKey(valid, structureId);
return this.delete(key) ? 1 : 0;
}
return this.invalidateAuthority(valid);
}
/** Invalidate every revision/scope entry for this user/principal/character. */
invalidateAuthority(context: PrivateStructureContext): number {
const valid = validatePrivateStructureContext(context);
let removed = 0;
for (const [key, entry] of this.entries) {
// Keys are stable JSON; parsing is safe because all keys are generated here.
const parsed = JSON.parse(key) as Partial<PrivateStructureContext> & { structureId?: string };
if (parsed.userId === valid.userId && parsed.principal === valid.principal && parsed.characterId === valid.characterId) {
this.delete(key); removed += 1;
}
}
return removed;
}
clear(): void { this.entries.clear(); this.bytes = 0; }
private delete(key: string): boolean {
const entry = this.entries.get(key);
if (!entry) return false;
this.entries.delete(key);
this.bytes -= entry.bytes;
return true;
}
private evict(): void {
while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
const oldest = this.entries.keys().next().value as string | undefined;
if (oldest === undefined) break;
this.delete(oldest);
}
}
}
/** A port that adds the bounded cache to a typed private ESI reader. */
/**
* Adapter from the fixed ESI structure operation to the authority-isolated
* cache port. It accepts a token provider rather than retaining tokens in the
* cache or its keys.
*/
export class EsiPrivateStructurePort implements PrivateStructurePort {
constructor(
private readonly esi: EsiClient,
private readonly accessToken: string | ((context: PrivateStructureContext) => Promise<string> | string),
private readonly now: () => number = () => Date.now(),
private readonly ttlMs = 60 * 60 * 1000,
private readonly hooks?: { beforeAccess?: () => Promise<void>; onTransient?: (error: unknown) => Promise<void> },
) {}
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
const valid = validatePrivateStructureContext(context);
assertDecimalId(structureId, 'structure ID');
await this.hooks?.beforeAccess?.();
const observedAt = this.now();
if (!Number.isFinite(observedAt)) throw new RangeError('private structure adapter clock is invalid');
if (!valid.scopes.includes(ESI_STRUCTURES_SCOPE)) {
return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'required structure scope is not granted' };
}
const token = typeof this.accessToken === 'function' ? await this.accessToken(valid) : this.accessToken;
try {
const observation = await this.esi.getStructure(valid.characterId, structureId, token, valid as AuthorizationKey);
return {
structureId, characterId: valid.characterId, status: 'accessible', evidence: 'esi_structure_access',
name: observation.name, observedAt: observation.retrievedAt, expiresAt: observation.expiresAt,
dockingPermission: 'unknown', solarSystemId: observation.solarSystemId, ownerId: observation.ownerId,
typeId: observation.typeId, position: observation.position,
};
} catch (error) {
if (error instanceof EsiHttpError && error.status === 403) {
return { structureId, characterId: valid.characterId, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI denied structure access' };
}
if (error instanceof EsiHttpError && error.status === 404) {
return { structureId, characterId: valid.characterId, status: 'unavailable', evidence: 'unavailable', name: null, observedAt, expiresAt: observedAt + this.ttlMs, dockingPermission: 'unknown', reason: 'ESI did not return the structure' };
}
if (error instanceof EsiRateLimitError || (error instanceof EsiError && (error.code === 'network' || error.code === 'deadline' || (error instanceof EsiHttpError && (error.status === 408 || error.status === 420 || error.status === 425 || error.status === 429 || error.status >= 500))))) await this.hooks?.onTransient?.(error);
// Transport/deadline/rate-limit failures remain errors so the worker can
// preserve retry/cooldown semantics; explicit 403/404 are observations.
if (error instanceof EsiError) throw error;
throw new Error('private structure lookup failed');
}
}
}
export class CachedPrivateStructurePort {
constructor(private readonly reader: PrivateStructureReader, private readonly cache = new PrivateStructureCache(), private readonly beforeAccess?: () => Promise<void>) {}
async access(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
await this.beforeAccess?.();
return await this.cache.getOrLoad(context, structureId, this.reader);
}
read(context: PrivateStructureContext, structureId: DecimalId): Promise<PrivateStructureAccessObservation> {
return this.access(context, structureId);
}
invalidate(context: PrivateStructureContext, structureId?: DecimalId): number { return this.cache.invalidate(context, structureId); }
getCache(): PrivateStructureCache { return this.cache; }
}
export const AuthIsolatedPrivateStructureCache = PrivateStructureCache;
export const PrivateStructureAccessCache = PrivateStructureCache;
+489
View File
@@ -0,0 +1,489 @@
import type {
Asset,
InventoryRow,
InventoryType,
Isk,
PriceEstimate,
PriceSnapshot,
SelectedResource,
} from '../../domain/model';
import {
compareDecimalIds,
isDecimalId,
validatePersistedLocationRows,
validateReferenceEnrichmentPin,
type EnrichedLocationNode,
type InventoryLocationAuthority,
type ReferenceEnrichmentPin,
} from '../../domain/inventory-location';
import { ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE } from '../../domain/capabilities';
import { multiplyIsk, parseIsk, toIsk, validateQuantity } from '../../domain/money';
/** Hard limits for a single immutable projection. Persistent adapters may use smaller limits. */
export const INVENTORY_PROJECTION_MAX_ROWS = 50_000;
export const INVENTORY_PROJECTION_MAX_TYPES = 50_000;
export const INVENTORY_PROJECTION_MAX_PRICES = 50_000;
export const MAX_INVENTORY_MONEY_TEXT_LENGTH = 128;
export const INVENTORY_LOCATION_ROW_CHUNK = 100;
export type ProjectionAuthority = {
userId: string;
characterId: number;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
/** Actual granted scopes, when supplied by the server authority reader. */
scopes?: readonly string[];
};
export type ProjectionLocation = {
itemId: string;
/** Resolved root location ID; unresolved rows retain only the source ID. */
locationId: string;
sourceLocationId: string;
resolution: 'resolved' | 'unresolved';
kind: 'station' | 'system' | 'region' | 'structure' | 'unknown' | null;
name: string | null;
systemId: number | null;
regionId: number | null;
evidence: 'verified' | 'system_listed' | 'private_structure_access' | 'unknown' | null;
unresolvedReasons: readonly string[];
};
/**
* A query row is deliberately richer than the shared UI row. The extra fields
* are private index metadata and are stripped only by consumers that need the
* narrower InventoryRow contract.
*/
export type InventoryProjectionRow = InventoryRow & {
resourceKey: string;
itemKey: string;
generation: string;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
typeName: string;
unitPrice: Isk | null;
pricing: 'priced' | 'unpriced';
location: ProjectionLocation;
/** True only when the location facts are explicitly unresolved. */
unresolvedLocation: boolean;
unresolvedReasons: readonly string[];
};
export type ProjectionCoverage = {
complete: boolean;
reason?: string;
};
export type InventoryProjectionInput = {
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
coverage?: ProjectionCoverage;
referenceVersion: string;
priceVersion?: string | null;
enrichmentVersion?: string | null;
fetchedAt?: number | null;
upstreamEffectiveAt?: number | null;
assets: readonly Asset[];
types: readonly InventoryType[];
prices?: PriceSnapshot | readonly PriceEstimate[];
/** T08 rows are optional; absence is explicit unresolved location coverage. */
locations?: readonly EnrichedLocationNode[];
/** Required whenever location rows are supplied; pins the exact T08 input. */
locationInputPin?: ReferenceEnrichmentPin;
};
export type InventoryProjectionManifest = {
version: 1;
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
status: 'complete' | 'partial';
coverage: ProjectionCoverage;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
locationInputPin: ReferenceEnrichmentPin | null;
rows: readonly InventoryProjectionRow[];
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
};
function validCounter(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function validTypeId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function canonicalScopes(scopes: readonly string[]): boolean {
return scopes.every((scope, index) => index === 0 || scopes[index - 1]! <= scope);
}
function stableReasonOrder(reasons: readonly string[]): boolean {
return reasons.every((reason, index) => index === 0 || reasons[index - 1]! < reason);
}
function resourceKey(resource: SelectedResource): string {
if (resource.principal.kind !== 'character') throw new TypeError('inventory projections require character resources');
return `character:${resource.principal.id}`;
}
function authorityContext(input: InventoryProjectionInput): InventoryLocationAuthority {
const scopes = [...(input.authority.scopes ?? input.resource.requiredScopes)].sort();
return {
userId: input.userId,
principal: `character:${input.authority.characterId}`,
characterId: input.authority.characterId,
scopes,
grantRevision: input.authority.grantRevision,
authorizationEpoch: input.authority.authorizationEpoch,
credentialVersion: input.authority.credentialVersion,
};
}
function canonicalMoney(value: unknown, field: string): Isk {
if (typeof value === 'string' && value.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError(`${field} exceeds money text bounds`);
let text: string;
try { text = toIsk(value as Isk); } catch { throw new TypeError(`${field} is invalid`); }
if (text.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError(`${field} exceeds money text bounds`);
parseIsk(text);
return text;
}
function validateResource(input: InventoryProjectionInput): string {
if (typeof input.userId !== 'string' || input.userId.length === 0 || input.userId.length > 256) throw new TypeError('projection user is required');
if (!input.resource || input.resource.principal.kind !== 'character') throw new TypeError('projection resource must be a character');
if (input.resource.principal.ownerUserId !== input.userId) throw new TypeError('projection resource owner mismatch');
if (!isDecimalId(input.resource.principal.id)) throw new TypeError('projection resource character ID is invalid');
if (!validCounter(input.authority.characterId) || input.authority.characterId <= 0) throw new TypeError('projection character ID is invalid');
if (String(input.authority.characterId) !== input.resource.principal.id) throw new TypeError('projection character resource mismatch');
if (input.authority.userId !== input.userId) throw new TypeError('projection authority owner mismatch');
if (!validCounter(input.authority.grantRevision) || !validCounter(input.authority.authorizationEpoch) || !validCounter(input.authority.credentialVersion)) throw new TypeError('projection authority counters are invalid');
if (input.resource.authorizationRevision !== input.authority.grantRevision || input.resource.authorizationEpoch !== input.authority.authorizationEpoch) throw new TypeError('projection authority revision mismatch');
const requiredScopes = input.resource.requiredScopes;
const grantedScopes = input.authority.scopes ?? requiredScopes;
if (!Array.isArray(requiredScopes) || requiredScopes.length > 64 || requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(requiredScopes) || new Set(requiredScopes).size !== requiredScopes.length || !requiredScopes.includes(ESI_ASSETS_SCOPE)) throw new TypeError('projection resource scopes are invalid');
if (!Array.isArray(grantedScopes) || grantedScopes.length > 64 || grantedScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(grantedScopes) || new Set(grantedScopes).size !== grantedScopes.length || !grantedScopes.includes(ESI_ASSETS_SCOPE) || requiredScopes.some((scope) => !grantedScopes.includes(scope))) throw new TypeError('projection authority scopes are invalid');
if (typeof input.generation !== 'string' || input.generation.length === 0 || input.generation.length > 256) throw new TypeError('projection generation is required');
if (typeof input.referenceVersion !== 'string' || input.referenceVersion.length === 0 || input.referenceVersion.length > 256) throw new TypeError('projection reference version is required');
if (input.priceVersion !== undefined && input.priceVersion !== null && (typeof input.priceVersion !== 'string' || input.priceVersion.length === 0 || input.priceVersion.length > 256)) throw new TypeError('projection price version is invalid');
if (input.enrichmentVersion !== undefined && input.enrichmentVersion !== null && (typeof input.enrichmentVersion !== 'string' || input.enrichmentVersion.length === 0 || input.enrichmentVersion.length > 256)) throw new TypeError('projection enrichment version is invalid');
return resourceKey(input.resource);
}
function locationFor(asset: Asset, byItem: Map<string, EnrichedLocationNode>): ProjectionLocation {
const enriched = byItem.get(asset.itemId);
if (!enriched) {
return {
itemId: asset.itemId,
locationId: asset.locationId,
sourceLocationId: asset.locationId,
resolution: 'unresolved',
kind: 'unknown',
name: null,
systemId: null,
regionId: null,
evidence: 'unknown',
unresolvedReasons: ['location_unavailable'],
};
}
const reference = enriched.location;
const system = reference.system;
const region = reference.region;
const unresolvedReasons = [...new Set(enriched.unresolved)].sort();
// T08's locationStatus describes optional per-item position helper data;
// `not_requested` does not make a known station/system unresolved. Explicit
// expired/forbidden/unavailable helper coverage remains unresolved.
const unresolved = reference.kind === 'unknown' || (reference.kind === 'structure' && reference.evidence === 'unknown') || (enriched.locationStatus !== 'resolved' && enriched.locationStatus !== 'not_requested') || unresolvedReasons.length > 0;
if (unresolved) {
const reasons = unresolvedReasons.length > 0 ? unresolvedReasons : (reference.kind === 'unknown' || reference.evidence === 'unknown') ? ['reference_unknown'] : [`location_${enriched.locationStatus}`];
return {
itemId: asset.itemId,
locationId: asset.locationId,
sourceLocationId: asset.locationId,
resolution: 'unresolved',
kind: 'unknown',
name: null,
systemId: null,
regionId: null,
evidence: 'unknown',
unresolvedReasons: reasons,
};
}
return {
itemId: asset.itemId,
locationId: reference.id,
sourceLocationId: asset.locationId,
resolution: 'resolved',
kind: reference.kind,
name: reference.name,
systemId: system && isDecimalId(system.id) && Number.isSafeInteger(Number(system.id)) ? Number(system.id) : null,
regionId: region && isDecimalId(region.id) && Number.isSafeInteger(Number(region.id)) ? Number(region.id) : null,
evidence: reference.evidence,
unresolvedReasons,
};
}
function validatedPrices(value: PriceSnapshot | readonly PriceEstimate[] | undefined): Map<number, Isk> {
if (value !== undefined && !Array.isArray(value) && !('prices' in value)) throw new TypeError('projection price response is malformed');
if (value !== undefined && !Array.isArray(value) && 'prices' in value) {
const snapshot = value as PriceSnapshot;
if (typeof snapshot.source !== 'string' || !snapshot.source || !Number.isFinite(snapshot.asOf) || !Number.isFinite(snapshot.expiresAt) || snapshot.expiresAt < snapshot.asOf) throw new TypeError('projection price snapshot metadata is malformed');
}
const entries: readonly PriceEstimate[] = value === undefined ? [] : Array.isArray(value) ? value : 'prices' in value ? value.prices : [];
if (!Array.isArray(entries) || entries.length > INVENTORY_PROJECTION_MAX_PRICES) throw new RangeError('projection price cap exceeded');
const result = new Map<number, Isk>();
for (const entry of entries) {
if (!entry || !validTypeId(entry.typeId) || result.has(entry.typeId)) throw new TypeError('projection price response is malformed');
let price: string;
try {
price = canonicalMoney(entry.unitPrice, 'projection price');
if (parseIsk(price) < 0n) throw new RangeError('negative price');
} catch (error) {
if (error instanceof RangeError && error.message.includes('bounds')) throw error;
throw new TypeError('projection price response is malformed');
}
result.set(entry.typeId, price);
}
return result;
}
function validatedTypes(types: readonly InventoryType[]): Map<number, InventoryType> {
if (!Array.isArray(types) || types.length > INVENTORY_PROJECTION_MAX_TYPES) throw new RangeError('projection type cap exceeded');
const result = new Map<number, InventoryType>();
for (const type of types) {
if (!type || !validTypeId(type.id) || result.has(type.id) || typeof type.name !== 'string' || !type.name.trim() || type.name.length > 512
|| !validTypeId(type.categoryId) || (type.groupId !== undefined && !validTypeId(type.groupId))) {
throw new TypeError('projection type response is malformed');
}
result.set(type.id, { ...type });
}
return result;
}
function validatedAsset(asset: unknown, characterId: number): asset is Asset {
if (!asset || typeof asset !== 'object') return false;
const candidate = asset as Partial<Asset>;
return isDecimalId(candidate.itemId) && isDecimalId(candidate.locationId) && candidate.characterId === characterId
&& validTypeId(candidate.typeId) && typeof candidate.quantity === 'number' && Number.isSafeInteger(candidate.quantity) && candidate.quantity >= 0
&& typeof candidate.locationType === 'string' && candidate.locationType.length > 0 && candidate.locationType.length <= 32 && typeof candidate.locationFlag === 'string' && candidate.locationFlag.length > 0 && candidate.locationFlag.length <= 128
&& (candidate.isSingleton === undefined || typeof candidate.isSingleton === 'boolean')
&& (candidate.isBlueprintCopy === undefined || typeof candidate.isBlueprintCopy === 'boolean');
}
function locationReferenceMatchesVersion(location: EnrichedLocationNode['location'], referenceVersion: string): boolean {
return location.referenceVersion === referenceVersion
&& (location.system === null || locationReferenceMatchesVersion(location.system, referenceVersion))
&& (location.region === null || locationReferenceMatchesVersion(location.region, referenceVersion));
}
function validateT08Locations(input: InventoryProjectionInput): void {
if (input.locations === undefined) {
if (input.locationInputPin !== undefined) throw new TypeError('location input pin requires location rows');
return;
}
if (!input.locationInputPin) throw new TypeError('location rows require an enrichment input pin');
const authority = authorityContext(input);
validateReferenceEnrichmentPin(input.locationInputPin, {
assetGeneration: input.generation,
referenceVersion: input.referenceVersion,
authority,
});
const assets = new Map<string, Partial<Asset>>();
for (const asset of input.assets) {
if (asset && typeof asset === 'object' && typeof (asset as Partial<Asset>).itemId === 'string') assets.set((asset as Partial<Asset>).itemId!, asset as Partial<Asset>);
}
const seen = new Set<string>();
for (let offset = 0; offset < input.locations.length; offset += INVENTORY_LOCATION_ROW_CHUNK) {
const chunk = input.locations.slice(offset, offset + INVENTORY_LOCATION_ROW_CHUNK);
validatePersistedLocationRows(chunk, { authority });
for (const row of chunk) {
const asset = assets.get(row.itemId);
if (!asset || row.characterId !== input.authority.characterId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || row.isSingleton !== asset.isSingleton || row.isBlueprintCopy !== asset.isBlueprintCopy || !locationReferenceMatchesVersion(row.location, input.referenceVersion) || seen.has(row.itemId)) throw new TypeError('location row does not match the asset generation');
seen.add(row.itemId);
}
}
}
/** Build one immutable, generation-bound projection. No database or network calls occur here. */
export function buildInventoryProjection(input: InventoryProjectionInput): InventoryProjectionManifest {
const key = validateResource(input);
if (!Array.isArray(input.assets) || input.assets.length > INVENTORY_PROJECTION_MAX_ROWS) throw new RangeError('projection row cap exceeded');
if (input.locations !== undefined && (!Array.isArray(input.locations) || input.locations.length > INVENTORY_PROJECTION_MAX_ROWS)) throw new RangeError('projection location row cap exceeded');
validateT08Locations(input);
const types = validatedTypes(input.types);
const prices = validatedPrices(input.prices);
const locationRows = new Map<string, EnrichedLocationNode>();
for (const row of input.locations ?? []) {
if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || row.characterId !== input.authority.characterId || locationRows.has(row.itemId)) throw new TypeError('projection location rows are malformed');
locationRows.set(row.itemId, row);
}
const seen = new Set<string>();
const rows: InventoryProjectionRow[] = [];
for (const source of input.assets) {
if (!validatedAsset(source, input.authority.characterId)) throw new TypeError('projection asset is malformed or belongs to another character');
if (seen.has(source.itemId)) throw new TypeError('projection contains duplicate item ID');
seen.add(source.itemId);
const type = types.get(source.typeId);
const location = locationFor(source, locationRows);
let unitPrice: Isk | null = null;
let estimatedValue: Isk | null = null;
let pricing: 'priced' | 'unpriced' = 'unpriced';
if (source.isBlueprintCopy !== true && type) {
unitPrice = prices.get(source.typeId) ?? null;
if (unitPrice !== null) {
if (input.priceVersion === undefined || input.priceVersion === null) throw new TypeError('priced projection rows require a price version');
// Keep the shared money/quantity functions at the arithmetic boundary;
// this is intentionally not a second valuation implementation.
validateQuantity(source.quantity);
estimatedValue = multiplyIsk(unitPrice, source.quantity);
if (estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new RangeError('projection estimated value exceeds money text bounds');
pricing = 'priced';
}
}
const row: InventoryProjectionRow = {
...source,
name: type?.name ?? 'Unknown type',
typeName: type?.name ?? 'Unknown type',
categoryId: type?.categoryId ?? null,
groupId: type?.groupId ?? null,
customName: locationRows.get(source.itemId)?.customName ?? null,
systemId: location.systemId,
regionId: location.regionId,
isSingleton: source.isSingleton ?? null,
estimatedValue,
resourceKey: key,
itemKey: `${input.authority.characterId}:${source.itemId}`,
generation: input.generation,
referenceVersion: input.referenceVersion,
priceVersion: input.priceVersion ?? null,
enrichmentVersion: input.enrichmentVersion ?? null,
unitPrice,
pricing,
location,
unresolvedLocation: location.resolution === 'unresolved',
unresolvedReasons: location.unresolvedReasons,
};
rows.push(row);
}
const coverage = input.coverage ?? { complete: true };
if (typeof coverage.complete !== 'boolean' || (coverage.reason !== undefined && (typeof coverage.reason !== 'string' || coverage.reason.length > 512)) || (!coverage.complete && (!coverage.reason || coverage.reason.length > 512))) throw new TypeError('projection coverage is malformed');
if ((input.fetchedAt !== undefined && input.fetchedAt !== null && !Number.isFinite(input.fetchedAt)) || (input.upstreamEffectiveAt !== undefined && input.upstreamEffectiveAt !== null && !Number.isFinite(input.upstreamEffectiveAt))) throw new TypeError('projection timestamps are invalid');
return {
version: 1,
userId: input.userId,
resource: { ...input.resource, requiredScopes: [...input.resource.requiredScopes] },
authority: { ...input.authority },
generation: input.generation,
status: coverage.complete ? 'complete' : 'partial',
coverage: { ...coverage },
referenceVersion: input.referenceVersion,
priceVersion: input.priceVersion ?? null,
enrichmentVersion: input.enrichmentVersion ?? null,
locationInputPin: input.locationInputPin ?? null,
rows,
fetchedAt: input.fetchedAt ?? null,
upstreamEffectiveAt: input.upstreamEffectiveAt ?? null,
};
}
function validateProjectionAuthority(userId: string, resource: SelectedResource, authority: ProjectionAuthority): void {
if (!resource || resource.principal.kind !== 'character' || resource.principal.ownerUserId !== userId || !isDecimalId(resource.principal.id)) throw new TypeError('inventory projection resource is invalid');
if (!Array.isArray(resource.requiredScopes) || resource.requiredScopes.length > 64 || resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(resource.requiredScopes) || new Set(resource.requiredScopes).size !== resource.requiredScopes.length || !resource.requiredScopes.includes(ESI_ASSETS_SCOPE)) throw new TypeError('inventory projection resource scopes are invalid');
if (!authority || !Number.isSafeInteger(Number(resource.principal.id)) || String(Number(resource.principal.id)) !== resource.principal.id || authority.userId !== userId || authority.characterId !== Number(resource.principal.id) || !Number.isSafeInteger(authority.characterId) || authority.characterId <= 0 || !validCounter(authority.grantRevision) || !validCounter(authority.authorizationEpoch) || !validCounter(authority.credentialVersion) || authority.grantRevision !== resource.authorizationRevision || authority.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('inventory projection authority is stale');
const scopes = authority.scopes ?? resource.requiredScopes;
if (!Array.isArray(scopes) || scopes.length > 64 || scopes.some((scope: unknown) => typeof scope !== 'string' || !scope) || !canonicalScopes(scopes) || new Set(scopes).size !== scopes.length || !scopes.includes(ESI_ASSETS_SCOPE) || resource.requiredScopes.some((scope) => !scopes.includes(scope))) throw new TypeError('inventory projection authority scopes are invalid');
}
function validateProjectionLocation(value: unknown, asset: { itemId: string; locationId: string }, scopes: readonly string[]): void {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection location is invalid');
const location = value as Partial<ProjectionLocation>;
const allowed = ['itemId', 'locationId', 'sourceLocationId', 'resolution', 'kind', 'name', 'systemId', 'regionId', 'evidence', 'unresolvedReasons'];
if (Object.keys(location).some((key) => !allowed.includes(key)) || location.itemId !== asset.itemId || !isDecimalId(location.locationId) || location.sourceLocationId !== asset.locationId || !isDecimalId(location.sourceLocationId)) throw new TypeError('inventory projection location identity is invalid');
if (location.resolution !== 'resolved' && location.resolution !== 'unresolved') throw new TypeError('inventory projection location resolution is invalid');
if (!Array.isArray(location.unresolvedReasons) || location.unresolvedReasons.length === 0 && location.resolution === 'unresolved' || location.unresolvedReasons.length > 24 || location.unresolvedReasons.some((reason) => typeof reason !== 'string' || reason.length > 128) || stableReasonOrder(location.unresolvedReasons) === false) throw new TypeError('inventory projection location reasons are invalid');
if (location.resolution === 'unresolved') {
if (location.locationId !== asset.locationId || location.kind !== 'unknown' || location.name !== null || location.systemId !== null || location.regionId !== null || location.evidence !== 'unknown') throw new TypeError('unresolved location contains inferred facts');
} else {
if (!['station', 'system', 'region', 'structure'].includes(location.kind ?? '') || (location.name !== null && (typeof location.name !== 'string' || location.name.length > 512)) || (location.systemId === undefined || location.systemId !== null && (!Number.isSafeInteger(location.systemId) || location.systemId <= 0)) || (location.regionId === undefined || location.regionId !== null && (!Number.isSafeInteger(location.regionId) || location.regionId <= 0)) || !['verified', 'system_listed', 'private_structure_access', 'unknown'].includes(location.evidence ?? '')) throw new TypeError('resolved location metadata is invalid');
if (location.kind === 'structure' && location.evidence !== 'private_structure_access') throw new TypeError('structure location evidence is invalid');
if (location.kind === 'structure' && location.evidence === 'private_structure_access' && !scopes.includes(ESI_STRUCTURES_SCOPE)) throw new TypeError('private structure location requires pinned structure scope');
if ((location.kind === 'system' || location.kind === 'region') && location.evidence !== 'verified') throw new TypeError('public location evidence is invalid');
if (location.kind === 'station' && location.evidence !== 'verified' && location.evidence !== 'system_listed') throw new TypeError('station location evidence is invalid');
}
}
export function validateInventoryProjectionRow(value: unknown, context: {
userId: string;
resource: SelectedResource;
authority: ProjectionAuthority;
generation: string;
referenceVersion: string;
priceVersion: string | null;
enrichmentVersion: string | null;
}): asserts value is InventoryProjectionRow {
validateProjectionAuthority(context.userId, context.resource, context.authority);
const source = value && typeof value === 'object' ? { ...(value as Record<string, unknown>), isSingleton: (value as Partial<InventoryProjectionRow>).isSingleton === null ? undefined : (value as Partial<InventoryProjectionRow>).isSingleton } : value;
if (!validatedAsset(source, context.authority.characterId)) throw new TypeError('inventory projection row source facts are invalid');
const row = value as InventoryProjectionRow;
if (row.resourceKey !== resourceKey(context.resource) || row.itemKey !== `${context.authority.characterId}:${row.itemId}` || row.generation !== context.generation || row.referenceVersion !== context.referenceVersion || row.priceVersion !== context.priceVersion || row.enrichmentVersion !== context.enrichmentVersion) throw new TypeError('inventory projection row pin is invalid');
if (typeof row.name !== 'string' || !row.name || row.name.length > 512 || typeof row.typeName !== 'string' || !row.typeName || row.typeName.length > 512 || (row.categoryId !== null && !validTypeId(row.categoryId)) || (row.groupId !== null && !validTypeId(row.groupId)) || (row.customName !== null && (typeof row.customName !== 'string' || row.customName.length > 512))) throw new TypeError('inventory projection row reference facts are invalid');
if (row.isSingleton !== null && typeof row.isSingleton !== 'boolean') throw new TypeError('inventory projection singleton fact is invalid');
if (row.pricing !== 'priced' && row.pricing !== 'unpriced') throw new TypeError('inventory projection pricing state is invalid');
const unitPrice = row.unitPrice === null ? null : canonicalMoney(row.unitPrice, 'inventory projection unit price');
const estimatedValue = row.estimatedValue === null ? null : canonicalMoney(row.estimatedValue, 'inventory projection estimated value');
if (row.pricing === 'priced') {
if (context.priceVersion === null || unitPrice === null || estimatedValue === null || parseIsk(unitPrice) < 0n || parseIsk(estimatedValue) < 0n || multiplyIsk(unitPrice, row.quantity) !== estimatedValue) throw new TypeError('inventory projection priced arithmetic is invalid');
} else if (unitPrice !== null || estimatedValue !== null) throw new TypeError('inventory projection unpriced arithmetic is invalid');
if (row.isBlueprintCopy === true && row.pricing !== 'unpriced') throw new TypeError('blueprint copy cannot be priced');
if (row.unresolvedLocation !== (row.location.resolution === 'unresolved')) throw new TypeError('inventory projection unresolved flag is invalid');
if (row.unresolvedReasons.join('\u0000') !== row.location.unresolvedReasons.join('\u0000')) throw new TypeError('inventory projection unresolved reasons do not match location');
validateProjectionLocation(row.location, row, context.authority.scopes ?? context.resource.requiredScopes);
if (row.systemId !== row.location.systemId || row.regionId !== row.location.regionId) throw new TypeError('inventory projection location index does not match evidence');
}
export function validateInventoryProjection(value: unknown): asserts value is InventoryProjectionManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('inventory projection is invalid');
const candidate = value as Partial<InventoryProjectionManifest>;
if (candidate.version !== 1 || (candidate.status !== 'complete' && candidate.status !== 'partial') || !Array.isArray(candidate.rows) || candidate.rows.length > INVENTORY_PROJECTION_MAX_ROWS) throw new TypeError('inventory projection manifest is invalid');
if (typeof candidate.userId !== 'string' || !candidate.userId || candidate.userId.length > 256 || typeof candidate.generation !== 'string' || !candidate.generation || candidate.generation.length > 256 || typeof candidate.referenceVersion !== 'string' || !candidate.referenceVersion || candidate.referenceVersion.length > 256) throw new TypeError('inventory projection pin is invalid');
if (candidate.priceVersion !== null && (typeof candidate.priceVersion !== 'string' || !candidate.priceVersion || candidate.priceVersion.length > 256)) throw new TypeError('inventory projection price pin is invalid');
if (candidate.enrichmentVersion !== null && (typeof candidate.enrichmentVersion !== 'string' || !candidate.enrichmentVersion || candidate.enrichmentVersion.length > 256)) throw new TypeError('inventory projection enrichment pin is invalid');
if (typeof candidate.coverage !== 'object' || candidate.coverage === null || typeof candidate.coverage.complete !== 'boolean' || (candidate.status === 'complete') !== candidate.coverage.complete || (candidate.coverage.reason !== undefined && (typeof candidate.coverage.reason !== 'string' || candidate.coverage.reason.length > 512)) || (candidate.status === 'partial' && (!candidate.coverage.reason || candidate.coverage.reason.length > 512))) throw new TypeError('inventory projection coverage is invalid');
if (candidate.fetchedAt !== null && (typeof candidate.fetchedAt !== 'number' || !Number.isFinite(candidate.fetchedAt)) || candidate.upstreamEffectiveAt !== null && (typeof candidate.upstreamEffectiveAt !== 'number' || !Number.isFinite(candidate.upstreamEffectiveAt))) throw new TypeError('inventory projection timestamps are invalid');
if (!candidate.resource || !candidate.authority) throw new TypeError('inventory projection authority is invalid');
validateProjectionAuthority(candidate.userId, candidate.resource, candidate.authority);
const hasResolvedLocation = candidate.rows.some((row) => !!row && typeof row === 'object' && (row as InventoryProjectionRow).location?.resolution === 'resolved');
if (candidate.locationInputPin === null) {
if (hasResolvedLocation) throw new TypeError('resolved projection rows require a location input pin');
} else {
validateReferenceEnrichmentPin(candidate.locationInputPin, { assetGeneration: candidate.generation, referenceVersion: candidate.referenceVersion, authority: authorityContext({ userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, assets: [], types: [] }) });
}
const seen = new Set<string>();
for (const row of candidate.rows) {
validateInventoryProjectionRow(row, { userId: candidate.userId, resource: candidate.resource, authority: candidate.authority, generation: candidate.generation, referenceVersion: candidate.referenceVersion, priceVersion: candidate.priceVersion, enrichmentVersion: candidate.enrichmentVersion });
if (seen.has(row.itemKey)) throw new TypeError('inventory projection contains duplicate item identity');
seen.add(row.itemKey);
}
}
export function projectionResourceKey(resource: SelectedResource): string {
return resourceKey(resource);
}
export function compareProjectionRows(left: InventoryProjectionRow, right: InventoryProjectionRow): number {
return left.characterId - right.characterId || compareDecimalIds(left.itemId, right.itemId);
}
/** Stable aliases used by the later Convex staging adapter. */
export const projectInventoryGeneration = buildInventoryProjection;
export const validateProjectedInventory = validateInventoryProjection;
@@ -0,0 +1,377 @@
import type {
InventoryFilters,
InventorySort,
Isk,
SelectedResource,
} from '../../domain/model';
import { compareDecimalIds, isDecimalId, stableSerialize } from '../../domain/inventory-location';
import { parseIsk, sumIsk, validateQuantity } from '../../domain/money';
import {
projectionResourceKey,
MAX_INVENTORY_MONEY_TEXT_LENGTH,
validateInventoryProjection,
type InventoryProjectionManifest,
type InventoryProjectionRow,
} from './projection';
import type { InventoryCursorState } from '../../domain/model';
export const INVENTORY_QUERY_MAX_PAGE_SIZE = 1_000;
export const INVENTORY_QUERY_MAX_MATCHING_ROWS = 50_000;
export const INVENTORY_QUERY_MAX_RESOURCES = 1_000;
export type InventoryResourceStatus = 'ready' | 'partial' | 'stale' | 'authorization_required' | 'unavailable';
/** Server-resolved metadata; callers cannot create authority by supplying this object. */
export type InventoryResourceSnapshot = {
userId: string;
resource: SelectedResource;
characterId: number;
characterName?: string;
status: InventoryResourceStatus;
generation: string | null;
referenceVersion: string | null;
priceVersion: string | null;
enrichmentVersion: string | null;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
grantedScopes?: readonly string[];
fetchedAt: number | null;
upstreamEffectiveAt: number | null;
reason?: string;
};
export type InventoryLocationSelector = 'unresolved';
export type InventoryPricingSelector = 'priced' | 'unpriced';
/** Normalized filter values consumed by persistence adapters. */
export type ValidatedInventoryFilters = {
characterIds: readonly string[];
typeIds: readonly number[];
customName: string | null;
categoryIds: readonly number[];
groupIds: readonly number[];
minQuantity: bigint | null;
maxQuantity: bigint | null;
locationIds: readonly string[];
systemIds: readonly number[];
regionIds: readonly number[];
blueprintState: 'all' | 'original' | 'copy' | 'unknown';
pricing: InventoryPricingSelector | null;
location: InventoryLocationSelector | null;
};
export type InventoryRepositoryPageRequest = {
callerUserId: string;
selectedResources: readonly SelectedResource[];
filters: ValidatedInventoryFilters;
sort: InventorySort;
offset: number;
pageSize: number;
};
export type InventoryRepositoryPage = {
status: 'ready';
rows: readonly InventoryProjectionRow[];
matchingRows: number;
matchingQuantity: string;
estimatedValue: Isk;
nextOffset: number | null;
resources: readonly InventoryResourceSnapshot[];
} | {
status: Exclude<InventoryResourceStatus, 'ready'>;
rows: readonly InventoryProjectionRow[];
matchingRows: number;
matchingQuantity: string;
estimatedValue: Isk | null;
nextOffset: number | null;
resources: readonly InventoryResourceSnapshot[];
reason: string;
};
/**
* The runtime seam T13/T07 will implement against Convex indexes. It is
* intentionally page-oriented: a production adapter must compute the match
* set and all-match aggregates in bounded indexed reads, rather than handing
* the query service an unbounded asset snapshot.
*/
export interface InventoryQueryRepository {
authorizedResources(userId: string): Promise<readonly InventoryResourceSnapshot[]>;
readPage(request: InventoryRepositoryPageRequest): Promise<InventoryRepositoryPage>;
getCursor(token: string): Promise<InventoryCursorState | null>;
putCursor(state: InventoryCursorState): Promise<void>;
replaceCursor(state: InventoryCursorState): Promise<void>;
}
export type InventoryProjectionWriter = {
stageProjection(manifest: InventoryProjectionManifest): Promise<void>;
publishProjection(userId: string, resource: SelectedResource, generation: string): Promise<void>;
};
function plainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], field: string): void {
if (Object.keys(value).some((key) => !allowed.includes(key))) throw new TypeError(`${field} contains unknown fields`);
}
function validateCursorRecord(value: unknown, field: string, allowNull = false): void {
if (!plainRecord(value)) throw new TypeError(`${field} is invalid`);
const keys = Object.keys(value);
if (keys.length > INVENTORY_QUERY_MAX_RESOURCES) throw new RangeError(`${field} cap exceeded`);
for (const key of keys) {
if (!key || typeof value[key] !== 'number' || !Number.isSafeInteger(value[key]) || (value[key] as number) < 0) {
if (allowNull && value[key] === null) continue;
throw new TypeError(`${field} contains an invalid value`);
}
}
}
/** Validate untrusted cursor envelopes before any domain cursor helper dereferences them. */
export function validateInventoryCursorState(value: unknown): asserts value is InventoryCursorState {
if (!plainRecord(value) || typeof value.token !== 'string' || value.token.length === 0 || value.token.length > 256 || (value.status !== 'active' && value.status !== 'exhausted') || !plainRecord(value.position) || !plainRecord(value.pin)) throw new TypeError('cursor state envelope is invalid');
exactKeys(value, ['token', 'status', 'position', 'pin'], 'cursor state');
exactKeys(value.position, ['offset', 'lastItemId'], 'cursor position');
exactKeys(value.pin, ['callerUserId', 'queryFingerprint', 'selectedResources', 'authorizationRevisions', 'authorizationEpochs', 'snapshotGenerations', 'referenceVersion', 'priceVersion', 'expiresAt'], 'cursor pin');
const position = value.position as { offset?: unknown; lastItemId?: unknown };
if (!Number.isSafeInteger(position.offset) || (position.offset as number) < 0 || (position.offset as number) > INVENTORY_QUERY_MAX_MATCHING_ROWS || ((position.offset as number) === 0 ? position.lastItemId !== null : !isDecimalId(position.lastItemId))) throw new TypeError('cursor state position is invalid');
const pin = value.pin;
if (typeof pin.callerUserId !== 'string' || pin.callerUserId.length === 0 || pin.callerUserId.length > 256 || typeof pin.queryFingerprint !== 'string' || pin.queryFingerprint.length === 0 || pin.queryFingerprint.length > 256 || !Array.isArray(pin.selectedResources) || pin.selectedResources.length === 0 || pin.selectedResources.length > INVENTORY_QUERY_MAX_RESOURCES || typeof pin.expiresAt !== 'number' || !Number.isFinite(pin.expiresAt) || pin.expiresAt < 0) throw new TypeError('cursor state pin is invalid');
const resourceKeys = new Set<string>();
for (const resource of pin.selectedResources) {
if (!plainRecord(resource) || !plainRecord(resource.principal)) throw new TypeError('cursor state resource pin is invalid');
exactKeys(resource, ['principal', 'requiredScopes', 'authorizationRevision', 'authorizationEpoch'], 'cursor resource');
exactKeys(resource.principal, ['kind', 'id', 'ownerUserId'], 'cursor principal');
const pinned = resource as unknown as SelectedResource;
if (pinned.principal.kind !== 'character' || !isDecimalId(pinned.principal.id) || typeof pinned.principal.ownerUserId !== 'string' || pinned.principal.ownerUserId.length > 256 || !Array.isArray(pinned.requiredScopes) || pinned.requiredScopes.length === 0 || pinned.requiredScopes.length > 64 || pinned.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(pinned.requiredScopes).size !== pinned.requiredScopes.length || !sameScopes(pinned.requiredScopes, [...pinned.requiredScopes].sort()) || resourceKeys.has(pinned.principal.id) || !Number.isSafeInteger(pinned.authorizationRevision) || pinned.authorizationRevision < 0 || !Number.isSafeInteger(pinned.authorizationEpoch) || pinned.authorizationEpoch < 0) throw new TypeError('cursor state resource pin is invalid');
resourceKeys.add(pinned.principal.id);
}
validateCursorRecord(pin.authorizationRevisions, 'cursor authorization revisions');
validateCursorRecord(pin.authorizationEpochs, 'cursor authorization epochs');
if (!plainRecord(pin.snapshotGenerations) || Object.keys(pin.snapshotGenerations).length > INVENTORY_QUERY_MAX_RESOURCES || Object.values(pin.snapshotGenerations).some((generation) => typeof generation !== 'string' || !generation || generation.length > 256)) throw new TypeError('cursor snapshot pin is invalid');
for (const map of [pin.authorizationRevisions, pin.authorizationEpochs, pin.snapshotGenerations]) {
if (!plainRecord(map) || Object.keys(map).length !== resourceKeys.size || Object.keys(map).some((key) => !key.startsWith('character:') || !resourceKeys.has(key.slice('character:'.length)))) throw new TypeError('cursor resource maps are invalid');
}
if (pin.referenceVersion !== null && (typeof pin.referenceVersion !== 'string' || !pin.referenceVersion || pin.referenceVersion.length > 256)) throw new TypeError('cursor reference pin is invalid');
if (pin.priceVersion !== null && (typeof pin.priceVersion !== 'string' || !pin.priceVersion || pin.priceVersion.length > 256)) throw new TypeError('cursor price pin is invalid');
}
function sortedScopes(scopes: readonly string[]): string[] { return [...scopes].sort(); }
function sameScopes(left: readonly string[], right: readonly string[]): boolean { return stableSerialize(sortedScopes(left)) === stableSerialize(sortedScopes(right)); }
function immutableEqual(left: unknown, right: unknown): boolean { return stableSerialize(left) === stableSerialize(right); }
function resourceMatches(left: SelectedResource, right: SelectedResource): boolean {
return left.principal.kind === right.principal.kind && left.principal.kind === 'character' && right.principal.kind === 'character'
&& left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId
&& left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch
&& sameScopes(left.requiredScopes, right.requiredScopes);
}
function compareTextValue(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function compareText(left: string | null, right: string | null): number {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
return compareTextValue(left, right);
}
function compareRows(left: InventoryProjectionRow, right: InventoryProjectionRow, sort: InventorySort): number {
let result = 0;
switch (sort.field) {
case 'character': result = left.characterId - right.characterId; break;
case 'name': result = compareTextValue(left.name, right.name) || compareTextValue(left.customName ?? '', right.customName ?? ''); break;
case 'quantity': result = validateQuantity(left.quantity) < validateQuantity(right.quantity) ? -1 : validateQuantity(left.quantity) > validateQuantity(right.quantity) ? 1 : 0; break;
case 'location': result = compareText(left.location.name, right.location.name)
|| (left.location.systemId === null ? (right.location.systemId === null ? 0 : 1) : right.location.systemId === null ? -1 : left.location.systemId - right.location.systemId)
|| (left.location.regionId === null ? (right.location.regionId === null ? 0 : 1) : right.location.regionId === null ? -1 : left.location.regionId - right.location.regionId);
break;
case 'estimated_value': {
if (left.estimatedValue === null && right.estimatedValue !== null) result = 1;
else if (left.estimatedValue !== null && right.estimatedValue === null) result = -1;
else if (left.estimatedValue !== null && right.estimatedValue !== null) result = parseIsk(left.estimatedValue) < parseIsk(right.estimatedValue) ? -1 : parseIsk(left.estimatedValue) > parseIsk(right.estimatedValue) ? 1 : 0;
break;
}
}
if (result !== 0) return sort.direction === 'ascending' ? result : -result;
// Decimal IDs cannot be compared as JS numbers. Character ID follows the
// item ID so equal ESI item IDs from two characters remain distinct.
result = compareDecimalIds(left.itemId, right.itemId) || left.characterId - right.characterId;
return result;
}
function matches(row: InventoryProjectionRow, filters: ValidatedInventoryFilters): boolean {
if (filters.characterIds.length > 0 && !filters.characterIds.includes(String(row.characterId))) return false;
if (filters.typeIds.length > 0 && !filters.typeIds.includes(row.typeId)) return false;
if (filters.categoryIds.length > 0 && (row.categoryId === null || !filters.categoryIds.includes(row.categoryId))) return false;
if (filters.groupIds.length > 0 && (row.groupId === null || !filters.groupIds.includes(row.groupId))) return false;
if (filters.customName !== null && !(row.customName ?? '').toLowerCase().includes(filters.customName.toLowerCase())) return false;
const quantity = validateQuantity(row.quantity);
if (filters.minQuantity !== null && quantity < filters.minQuantity) return false;
if (filters.maxQuantity !== null && quantity > filters.maxQuantity) return false;
if (filters.pricing !== null && row.pricing !== filters.pricing) return false;
if (filters.blueprintState === 'copy' && row.isBlueprintCopy !== true) return false;
if (filters.blueprintState === 'original' && row.isBlueprintCopy !== false) return false;
if (filters.blueprintState === 'unknown' && row.isBlueprintCopy !== undefined && row.isBlueprintCopy !== null) return false;
const hasLocationFilter = filters.location !== null || filters.locationIds.length > 0 || filters.systemIds.length > 0 || filters.regionIds.length > 0;
if (!hasLocationFilter) return true; // unresolved rows are included by the unfiltered view.
if (filters.location === 'unresolved') return row.unresolvedLocation;
if (row.unresolvedLocation) return false; // never infer a named location from an unresolved ID.
if (filters.locationIds.length > 0 && !filters.locationIds.includes(row.location.locationId)) return false;
if (filters.systemIds.length > 0 && (row.systemId === null || !filters.systemIds.includes(row.systemId))) return false;
if (filters.regionIds.length > 0 && (row.regionId === null || !filters.regionIds.includes(row.regionId))) return false;
return true;
}
function validatePageRequest(request: InventoryRepositoryPageRequest): void {
if (!request || typeof request.callerUserId !== 'string' || !request.callerUserId) throw new TypeError('repository caller is invalid');
if (!Array.isArray(request.selectedResources) || request.selectedResources.length === 0) throw new TypeError('repository resource selection is empty');
if (!Number.isSafeInteger(request.offset) || request.offset < 0 || request.offset > INVENTORY_QUERY_MAX_MATCHING_ROWS || !Number.isSafeInteger(request.pageSize) || request.pageSize < 1 || request.pageSize > INVENTORY_QUERY_MAX_PAGE_SIZE) throw new RangeError('repository page bounds are invalid');
if (!request.sort || request.sort.tieBreaker !== 'item_id') throw new TypeError('repository sort is invalid');
}
/**
* Bounded fixture adapter used by the pure-core tests. It is deliberately
* exported as a fixture-grade adapter, not presented as the Convex delivery.
* The production implementation must satisfy the same page-oriented port.
*/
export class InMemoryInventoryQueryRepository implements InventoryQueryRepository, InventoryProjectionWriter {
private readonly active = new Map<string, InventoryProjectionManifest>();
private readonly staged = new Map<string, InventoryProjectionManifest>();
private readonly resources = new Map<string, InventoryResourceSnapshot>();
private readonly cursors = new Map<string, InventoryCursorState>();
constructor(initial: readonly InventoryProjectionManifest[] = []) {
for (const manifest of initial) {
validateInventoryProjection(manifest);
const key = this.key(manifest.userId, manifest.resource, manifest.generation);
const existing = this.staged.get(key);
if (existing && !immutableEqual(existing, manifest)) throw new Error('immutable projection snapshot conflict');
if (!existing) this.staged.set(key, structuredClone(manifest));
const staged = this.staged.get(key)!;
if (manifest.status === 'complete') {
const active = this.active.get(key);
if (active && !immutableEqual(active, staged)) throw new Error('immutable published projection conflict');
if (!active) this.active.set(key, structuredClone(staged));
}
this.upsertResource(manifest, manifest.status === 'complete' ? 'ready' : 'partial');
}
}
async stageProjection(manifest: InventoryProjectionManifest): Promise<void> {
validateInventoryProjection(manifest);
if (manifest.rows.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository projection cap exceeded');
const key = this.key(manifest.userId, manifest.resource, manifest.generation);
const existing = this.staged.get(key);
if (existing) {
if (!immutableEqual(existing, manifest)) throw new Error('immutable projection snapshot conflict');
return;
}
this.staged.set(key, structuredClone(manifest));
}
async publishProjection(userId: string, resource: SelectedResource, generation: string): Promise<void> {
const staged = this.staged.get(this.key(userId, resource, generation));
if (!staged) throw new Error('projection generation is not staged');
if (staged.userId !== userId || !resourceMatches(resource, staged.resource)) throw new Error('projection authority fence changed');
if (staged.status !== 'complete' || !staged.coverage.complete) throw new Error('partial projection cannot be published as complete');
const key = this.key(userId, resource, generation);
const existing = this.active.get(key);
if (existing && !immutableEqual(existing, staged)) throw new Error('immutable published projection conflict');
if (!existing) this.active.set(key, structuredClone(staged));
this.upsertResource(staged, 'ready');
}
/** Seed an authorization/coverage state with no active rows for unavailable tests. */
seedResource(snapshot: InventoryResourceSnapshot): void {
if (!snapshot || typeof snapshot.userId !== 'string' || !snapshot.userId || snapshot.resource.principal.kind !== 'character') throw new TypeError('resource snapshot is invalid');
this.resources.set(this.resourceKey(snapshot.userId, snapshot.resource), { ...snapshot, resource: { ...snapshot.resource, requiredScopes: [...snapshot.resource.requiredScopes] } });
}
async authorizedResources(userId: string): Promise<readonly InventoryResourceSnapshot[]> {
if (typeof userId !== 'string' || !userId) throw new TypeError('repository user is invalid');
return [...this.resources.values()].filter((resource) => resource.userId === userId).sort((left, right) => left.characterId - right.characterId);
}
async readPage(request: InventoryRepositoryPageRequest): Promise<InventoryRepositoryPage> {
validatePageRequest(request);
const available = await this.authorizedResources(request.callerUserId);
const selected = available.filter((candidate) => request.selectedResources.some((resource) => resourceMatches(resource, candidate.resource)));
if (selected.length !== request.selectedResources.length) return this.failedPage('authorization_required', selected, 'selected resource is not authorized');
if (selected.some((resource) => resource.status !== 'ready')) {
const failed = selected.find((resource) => resource.status !== 'ready')!;
const failedStatus = failed.status === 'ready' ? 'unavailable' : failed.status;
return this.failedPage(failedStatus, selected, failed.reason ?? `resource ${failed.characterId} coverage is unavailable`);
}
const manifests: InventoryProjectionManifest[] = [];
for (const resource of selected) {
if (!resource.generation) return this.failedPage('unavailable', selected, `resource ${resource.characterId} has no generation`);
const manifest = this.active.get(this.key(request.callerUserId, resource.resource, resource.generation));
if (!manifest || manifest.status !== 'complete' || !manifest.coverage.complete) return this.failedPage('unavailable', selected, `resource ${resource.characterId} generation is unavailable`);
const manifestScopes = manifest.authority.scopes ?? manifest.resource.requiredScopes;
if (manifest.authority.userId !== resource.userId || !resourceMatches(manifest.resource, resource.resource) || manifest.authority.characterId !== resource.characterId || manifest.authority.grantRevision !== resource.grantRevision || manifest.authority.authorizationEpoch !== resource.authorizationEpoch || manifest.authority.credentialVersion !== resource.credentialVersion || !sameScopes(manifestScopes, resource.grantedScopes ?? resource.resource.requiredScopes) || manifest.referenceVersion !== resource.referenceVersion || manifest.priceVersion !== resource.priceVersion || manifest.enrichmentVersion !== resource.enrichmentVersion) return this.failedPage('stale', selected, `resource ${resource.characterId} projection authority or input pin changed`);
manifests.push(manifest);
}
const rows = manifests.flatMap((manifest) => manifest.rows);
if (rows.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository match cap exceeded');
const matching = rows.filter((row) => matches(row, request.filters)).sort((left, right) => compareRows(left, right, request.sort));
if (matching.length > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('repository matching cap exceeded');
let matchingQuantity = 0n;
for (const row of matching) matchingQuantity += validateQuantity(row.quantity);
const estimatedValue = sumIsk(matching.map((row) => row.estimatedValue ?? '0'));
const page = matching.slice(request.offset, request.offset + request.pageSize);
const nextOffset = request.offset + page.length < matching.length ? request.offset + page.length : null;
return { status: 'ready', rows: page, matchingRows: matching.length, matchingQuantity: matchingQuantity.toString(10), estimatedValue, nextOffset, resources: selected };
}
async getCursor(token: string): Promise<InventoryCursorState | null> {
if (typeof token !== 'string' || !token) throw new TypeError('cursor token is invalid');
const state = this.cursors.get(token);
return state ? structuredClone(state) : null;
}
async putCursor(state: InventoryCursorState): Promise<void> {
validateInventoryCursorState(state);
if (this.cursors.has(state.token)) throw new Error('cursor token already exists');
this.cursors.set(state.token, structuredClone(state));
}
async replaceCursor(state: InventoryCursorState): Promise<void> {
validateInventoryCursorState(state);
const existing = this.cursors.get(state.token);
if (!existing) throw new Error('cursor token does not exist');
if (!immutableEqual(existing.pin, state.pin) || existing.token !== state.token) throw new Error('cursor pin is immutable');
this.cursors.set(state.token, structuredClone(state));
}
private failedPage(status: Exclude<InventoryResourceStatus, 'ready'>, resources: readonly InventoryResourceSnapshot[], reason: string): InventoryRepositoryPage {
return { status, rows: [], matchingRows: 0, matchingQuantity: '0', estimatedValue: null, nextOffset: null, resources, reason };
}
private upsertResource(manifest: InventoryProjectionManifest, status: InventoryResourceStatus): void {
this.resources.set(this.resourceKey(manifest.userId, manifest.resource), {
userId: manifest.userId,
resource: { ...manifest.resource, requiredScopes: [...manifest.resource.requiredScopes] },
characterId: manifest.authority.characterId,
status,
generation: manifest.generation,
referenceVersion: manifest.referenceVersion,
priceVersion: manifest.priceVersion,
enrichmentVersion: manifest.enrichmentVersion,
grantRevision: manifest.authority.grantRevision,
authorizationEpoch: manifest.authority.authorizationEpoch,
credentialVersion: manifest.authority.credentialVersion,
grantedScopes: [...(manifest.authority.scopes ?? manifest.resource.requiredScopes)],
fetchedAt: manifest.fetchedAt,
upstreamEffectiveAt: manifest.upstreamEffectiveAt,
});
}
private resourceKey(userId: string, resource: SelectedResource): string { return `${userId}\u0000${projectionResourceKey(resource)}`; }
private key(userId: string, resource: SelectedResource, generation: string): string { return `${this.resourceKey(userId, resource)}\u0000${generation}`; }
}
export type { InventoryFilters };
+618
View File
@@ -0,0 +1,618 @@
import { randomUUID } from 'node:crypto';
import type {
CompleteReportProvenance,
DatasetKind,
InventoryCursor,
InventoryCursorLookup,
InventoryCursorPin,
InventoryCursorState,
InventoryFilters,
InventoryQueryResult,
InventoryRow,
InventorySort,
Isk,
SelectedResource,
} from '../../domain/model';
import { ESI_ASSETS_SCOPE, validateInventoryCursorUse, validateInventoryPageResult } from '../../domain/capabilities';
import { compareDecimalIds, isDecimalId, stableSerialize } from '../../domain/inventory-location';
import { parseIsk, validateQuantity } from '../../domain/money';
import {
INVENTORY_QUERY_MAX_MATCHING_ROWS,
INVENTORY_QUERY_MAX_PAGE_SIZE,
type InventoryLocationSelector,
type InventoryPricingSelector,
type InventoryQueryRepository,
type InventoryRepositoryPage,
type InventoryRepositoryPageRequest,
type InventoryResourceSnapshot,
validateInventoryCursorState,
type ValidatedInventoryFilters,
} from './query-repository';
import { MAX_INVENTORY_MONEY_TEXT_LENGTH, validateInventoryProjectionRow, type InventoryProjectionRow } from './projection';
export const DEFAULT_INVENTORY_CURSOR_TTL_MS = 15 * 60 * 1_000;
export const MAX_INVENTORY_CURSOR_TTL_MS = 24 * 60 * 60 * 1_000;
export const INVENTORY_QUERY_MAX_FILTER_VALUES = 1_000;
export const MAX_INVENTORY_CURSOR_TOKEN_LENGTH = 256;
export type InventoryQueryRequest = {
callerUserId: string;
/** Omit to select all currently linked, server-authorized characters. */
selectedResources?: readonly SelectedResource[];
filters: InventoryFilters & {
pricing?: InventoryPricingSelector;
location?: InventoryLocationSelector;
};
sort?: InventorySort;
pageSize?: number;
cursor?: InventoryCursor | string | null;
};
export type InventoryQueryServiceOptions = {
repository: InventoryQueryRepository;
now?: () => number;
cursorTtlMs?: number;
maxPageSize?: number;
};
type NormalizedRequest = {
callerUserId: string;
selectedResources: readonly SelectedResource[];
resourceSelectionExplicit: boolean;
filters: ValidatedInventoryFilters;
sort: InventorySort;
pageSize: number;
cursor: InventoryCursor | null;
};
type CursorFailure = 'authorization_required' | 'stale' | 'unavailable';
function finiteNow(clock: () => number): number {
const value = clock();
if (!Number.isFinite(value)) throw new RangeError('inventory query clock returned an invalid timestamp');
return value;
}
function validIdList(value: unknown, field: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`);
const result = value.map((id, index) => {
if (!isDecimalId(id)) throw new TypeError(`${field}[${index}] is invalid`);
return id;
});
if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`);
return [...result].sort(compareDecimalIds);
}
function validNumberList(value: unknown, field: string): number[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError(`${field} filter cap exceeded`);
const result = value.map((id, index) => {
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new TypeError(`${field}[${index}] is invalid`);
return id;
});
if (new Set(result).size !== result.length) throw new TypeError(`${field} contains duplicate IDs`);
return [...result].sort((left, right) => left - right);
}
function validQuantity(value: unknown, field: string): bigint | null {
if (value === undefined) return null;
if (typeof value !== 'string' || !/^(?:0|[1-9][0-9]*)$(?![\s\S])/.test(value) || value.length > 128) throw new TypeError(`${field} must be a bounded non-negative integer string`);
return BigInt(value);
}
function normalizeFilters(input: unknown): ValidatedInventoryFilters {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('inventory filters are required');
const raw = input as Record<string, unknown>;
const allowed = new Set(['characterIds', 'typeIds', 'customName', 'categoryIds', 'groupIds', 'minQuantity', 'maxQuantity', 'locationIds', 'systemIds', 'regionIds', 'blueprintState', 'pricing', 'location']);
for (const key of Object.keys(raw)) if (!allowed.has(key)) throw new TypeError(`unsupported inventory filter: ${key}`);
const typeIds = validNumberList(raw.typeIds, 'typeIds');
const categoryIds = validNumberList(raw.categoryIds, 'categoryIds');
const groupIds = validNumberList(raw.groupIds, 'groupIds');
const systemIds = validNumberList(raw.systemIds, 'systemIds');
const regionIds = validNumberList(raw.regionIds, 'regionIds');
const locationValues = raw.locationIds === undefined ? [] : raw.locationIds;
if (!Array.isArray(locationValues) || locationValues.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('locationIds filter cap exceeded');
const locationIds: string[] = [];
let location: InventoryLocationSelector | null = raw.location === undefined ? null : raw.location === 'Unresolved' ? 'unresolved' : raw.location as InventoryLocationSelector;
for (const [index, value] of locationValues.entries()) {
if (value === 'unresolved' || value === 'Unresolved') {
if (location !== null && location !== 'unresolved') throw new TypeError('location selectors conflict');
location = 'unresolved';
} else if (isDecimalId(value)) locationIds.push(value);
else throw new TypeError(`locationIds[${index}] is invalid`);
}
if (location !== null && location !== 'unresolved') throw new TypeError('location filter is invalid');
if (location === 'unresolved' && (locationIds.length > 0 || systemIds.length > 0 || regionIds.length > 0)) throw new TypeError('unresolved location filter cannot be combined with named location filters');
if (raw.customName !== undefined && (typeof raw.customName !== 'string' || raw.customName.length > 512)) throw new TypeError('customName filter is invalid');
if (raw.blueprintState !== undefined && !['all', 'original', 'copy', 'unknown'].includes(raw.blueprintState as string)) throw new TypeError('blueprintState filter is invalid');
const pricing = raw.pricing === undefined ? null : raw.pricing as InventoryPricingSelector;
if (pricing !== null && pricing !== 'priced' && pricing !== 'unpriced') throw new TypeError('pricing filter is invalid');
const minQuantity = validQuantity(raw.minQuantity, 'minQuantity');
const maxQuantity = validQuantity(raw.maxQuantity, 'maxQuantity');
if (minQuantity !== null && maxQuantity !== null && minQuantity > maxQuantity) throw new RangeError('quantity filter range is invalid');
return {
characterIds: validIdList(raw.characterIds, 'characterIds'),
typeIds,
customName: raw.customName === undefined || raw.customName === '' ? null : raw.customName as string,
categoryIds,
groupIds,
minQuantity,
maxQuantity,
blueprintState: (raw.blueprintState ?? 'all') as ValidatedInventoryFilters['blueprintState'],
locationIds: [...new Set(locationIds)].sort(compareDecimalIds),
systemIds,
regionIds,
pricing,
location,
};
}
function normalizeSort(value: unknown): InventorySort {
if (!value || typeof value !== 'object') throw new TypeError('inventory sort is required');
const candidate = value as Partial<InventorySort>;
if (!['character', 'name', 'quantity', 'location', 'estimated_value'].includes(candidate.field as string)) throw new TypeError('inventory sort field is invalid');
if (candidate.direction !== 'ascending' && candidate.direction !== 'descending') throw new TypeError('inventory sort direction is invalid');
if (candidate.tieBreaker !== 'item_id') throw new TypeError('inventory sort tie-breaker is invalid');
return { field: candidate.field!, direction: candidate.direction, tieBreaker: 'item_id' };
}
function normalizeCursor(value: unknown): InventoryCursor | null {
if (value === undefined || value === null) return null;
if (typeof value === 'string') {
if (!value) throw new TypeError('inventory cursor token is empty');
if (value.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized');
return { token: value };
}
if (!value || typeof value !== 'object' || typeof (value as InventoryCursor).token !== 'string' || !(value as InventoryCursor).token) throw new TypeError('inventory cursor is malformed');
if ((value as InventoryCursor).token.length > MAX_INVENTORY_CURSOR_TOKEN_LENGTH) throw new TypeError('inventory cursor token is oversized');
return { token: (value as InventoryCursor).token };
}
function sameScopeOrder(scopes: readonly string[]): boolean {
return scopes.every((scope, index) => index === 0 || scopes[index - 1]! <= scope);
}
function samePrincipal(left: SelectedResource, right: SelectedResource): boolean {
return left.principal.kind === 'character' && right.principal.kind === 'character'
&& left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId;
}
function sameResource(left: SelectedResource, right: SelectedResource): boolean {
return samePrincipal(left, right) && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch
&& left.requiredScopes.length === right.requiredScopes.length && left.requiredScopes.every((scope, index) => scope === right.requiredScopes[index]);
}
function resourceSelectionKey(resource: SelectedResource): string {
return resource.principal.kind === 'character'
? `character\u0000${resource.principal.ownerUserId}\u0000${resource.principal.id}`
: `${resource.principal.kind}\u0000${'id' in resource.principal ? resource.principal.id : ''}`;
}
function snapshotSelectionKey(resource: InventoryResourceSnapshot): string {
return `character\u0000${resource.userId}\u0000${resource.characterId}`;
}
function sameSnapshotPins(left: InventoryResourceSnapshot, right: InventoryResourceSnapshot): boolean {
const leftScopes = left.grantedScopes ?? left.resource.requiredScopes;
const rightScopes = right.grantedScopes ?? right.resource.requiredScopes;
return left.userId === right.userId && left.characterId === right.characterId && sameResource(left.resource, right.resource)
&& left.status === right.status && left.generation === right.generation && left.referenceVersion === right.referenceVersion
&& left.priceVersion === right.priceVersion && left.enrichmentVersion === right.enrichmentVersion
&& left.grantRevision === right.grantRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion
&& left.fetchedAt === right.fetchedAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt
&& leftScopes.length === rightScopes.length && leftScopes.every((scope, index) => scope === rightScopes[index]);
}
function canonicalResources(resources: readonly SelectedResource[]): SelectedResource[] {
return [...resources].sort((left, right) => {
if (left.principal.kind !== 'character' || right.principal.kind !== 'character') return 0;
return compareDecimalIds(left.principal.id, right.principal.id);
}).map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes].sort() }));
}
function copyCanonicalResources(resources: readonly SelectedResource[]): SelectedResource[] {
return resources.map((resource) => ({ ...resource, requiredScopes: [...resource.requiredScopes] }));
}
function canonicalSnapshots(resources: readonly InventoryResourceSnapshot[]): InventoryResourceSnapshot[] {
return [...resources].sort((left, right) => compareDecimalIds(String(left.characterId), String(right.characterId)));
}
function digest(value: unknown): string {
const text = stableSerialize(value);
let first = 2166136261;
let second = 2166136261 ^ 0x9e3779b9;
for (let index = 0; index < text.length; index += 1) {
const code = text.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')}`;
}
function versionDigest(values: readonly (string | null)[]): string | null {
const unique = [...new Set(values)];
if (unique.length === 0 || unique.every((value) => value === null)) return null;
return unique.length === 1 ? unique[0]! : `multi-${digest(unique.sort())}`;
}
function resourceMaps(resources: readonly InventoryResourceSnapshot[]) {
const authorizationRevisions: Record<string, number> = {};
const authorizationEpochs: Record<string, number> = {};
const snapshotGenerations: Record<string, string> = {};
const credentialVersions: Record<string, number> = {};
const enrichmentVersions: Record<string, string | null> = {};
for (const resource of canonicalSnapshots(resources)) {
const key = `character:${resource.characterId}`;
authorizationRevisions[key] = resource.grantRevision;
authorizationEpochs[key] = resource.authorizationEpoch;
credentialVersions[key] = resource.credentialVersion;
if (resource.generation) snapshotGenerations[key] = resource.generation;
enrichmentVersions[key] = resource.enrichmentVersion;
}
return { authorizationRevisions, authorizationEpochs, snapshotGenerations, credentialVersions, enrichmentVersions };
}
function queryFingerprint(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): string {
const maps = resourceMaps(resources);
return `inventory-${digest({
callerUserId: request.callerUserId,
resources: copyCanonicalResources(request.selectedResources),
filters: {
...request.filters,
minQuantity: request.filters.minQuantity?.toString() ?? null,
maxQuantity: request.filters.maxQuantity?.toString() ?? null,
},
sort: request.sort,
pageSize: request.pageSize,
...maps,
referenceVersions: canonicalSnapshots(resources).map((resource) => resource.referenceVersion),
priceVersions: canonicalSnapshots(resources).map((resource) => resource.priceVersion),
})}`;
}
function cursorPin(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, expiresAt: number): InventoryCursorPin {
const maps = resourceMaps(resources);
return {
callerUserId: request.callerUserId,
queryFingerprint: fingerprint,
selectedResources: copyCanonicalResources(request.selectedResources),
authorizationRevisions: maps.authorizationRevisions,
authorizationEpochs: maps.authorizationEpochs,
snapshotGenerations: maps.snapshotGenerations,
referenceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.referenceVersion)),
priceVersion: versionDigest(canonicalSnapshots(resources).map((resource) => resource.priceVersion)),
expiresAt,
};
}
function cursorLookup(token: string, request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[], fingerprint: string, now: number): InventoryCursorLookup {
const pin = cursorPin(request, resources, fingerprint, now);
return { token, callerUserId: request.callerUserId, queryFingerprint: fingerprint, selectedResources: pin.selectedResources, authorizationRevisions: pin.authorizationRevisions, authorizationEpochs: pin.authorizationEpochs, snapshotGenerations: pin.snapshotGenerations, referenceVersion: pin.referenceVersion, priceVersion: pin.priceVersion, now };
}
type NonReadyInventoryResult = Exclude<InventoryQueryResult, { status: 'ready' }>;
function invalidCursorResult(failure: CursorFailure, reason: string): NonReadyInventoryResult {
return { status: failure, rows: [], summary: null, reason };
}
function provenance(resources: readonly InventoryResourceSnapshot[], now: number, priceVersion: string | null, referenceVersion: string | null): CompleteReportProvenance {
const orderedResources = canonicalSnapshots(resources);
const selectedResources = copyCanonicalResources(orderedResources.map((resource) => resource.resource));
const datasetPins = orderedResources.map((resource) => ({
selectedResource: resource.resource,
principal: resource.resource.principal,
dataset: 'assets' as const,
coverage: { kind: 'snapshot' as const, generation: resource.generation!, complete: true as const },
authorizationRevision: resource.grantRevision,
authorizationEpoch: resource.authorizationEpoch,
fetchedAt: resource.fetchedAt,
upstreamEffectiveAt: resource.upstreamEffectiveAt,
}));
const maps = resourceMaps(orderedResources);
return {
reportId: `inventory-${digest({ selectedResources, datasetPins, now })}`,
generatedAt: now,
fetchedAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.fetchedAt])),
upstreamEffectiveAt: Object.fromEntries(orderedResources.map((resource) => [`character:${resource.characterId}`, resource.upstreamEffectiveAt])),
input: {
selectedResources,
requiredDatasetKinds: ['assets'] as readonly [DatasetKind],
pinSet: 'exact_selected_resource_dataset_set',
datasetPins: datasetPins as unknown as CompleteReportProvenance['input']['datasetPins'],
authorizationRevisions: maps.authorizationRevisions,
authorizationEpochs: maps.authorizationEpochs,
snapshotGenerations: maps.snapshotGenerations,
appendCheckpoints: {},
referenceVersion,
priceVersion,
},
sourceSkew: false,
assumptions: ['Reference and price inputs are immutable versions pinned by the projection manifest.'],
};
}
function stripRows(rows: readonly InventoryProjectionRow[]): readonly InventoryRow[] {
// Keep only the documented public inventory projection. In particular, never
// spread persisted rows: adapters may contain private index fields or
// credential-like additions that must not cross the query boundary.
return rows.map((row) => ({
itemId: row.itemId,
characterId: row.characterId,
typeId: row.typeId,
quantity: row.quantity,
locationId: row.locationId,
locationType: row.locationType,
locationFlag: row.locationFlag,
isSingleton: row.isSingleton,
isBlueprintCopy: row.isBlueprintCopy,
name: row.name,
typeName: row.typeName,
categoryId: row.categoryId,
groupId: row.groupId,
customName: row.customName,
systemId: row.systemId,
regionId: row.regionId,
estimatedValue: row.estimatedValue,
unitPrice: row.unitPrice,
pricing: row.pricing,
unresolvedLocation: row.unresolvedLocation,
unresolvedReasons: [...row.unresolvedReasons],
location: {
itemId: row.location.itemId,
locationId: row.location.locationId,
sourceLocationId: row.location.sourceLocationId,
resolution: row.location.resolution,
kind: row.location.kind,
name: row.location.name,
systemId: row.location.systemId,
regionId: row.location.regionId,
evidence: row.location.evidence,
unresolvedReasons: [...row.location.unresolvedReasons],
},
})) as unknown as readonly InventoryRow[];
}
function validateRepositoryPage(page: InventoryRepositoryPage, request: NormalizedRequest, offset: number): void {
if (!page || typeof page !== 'object' || !['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes((page as { status?: unknown }).status as string) || !Array.isArray(page.rows) || page.rows.length > request.pageSize) throw new TypeError('inventory repository returned an invalid page');
if (!Array.isArray(page.resources) || page.resources.length !== request.selectedResources.length) throw new TypeError('inventory repository returned an invalid resource pin');
if (!Number.isSafeInteger(page.matchingRows) || page.matchingRows < 0 || page.matchingRows > INVENTORY_QUERY_MAX_MATCHING_ROWS) throw new RangeError('inventory repository matching row cap exceeded');
if (page.nextOffset !== null && (!Number.isSafeInteger(page.nextOffset) || page.nextOffset < 0 || page.nextOffset > INVENTORY_QUERY_MAX_MATCHING_ROWS)) throw new TypeError('inventory repository continuation offset is invalid');
if (page.status === 'ready') {
if (page.rows.length > page.matchingRows || typeof page.matchingQuantity !== 'string' || page.matchingQuantity.length > MAX_INVENTORY_MONEY_TEXT_LENGTH || !/^(?:0|[1-9][0-9]*)$/.test(page.matchingQuantity) || typeof page.estimatedValue !== 'string' || page.estimatedValue.length > MAX_INVENTORY_MONEY_TEXT_LENGTH) throw new TypeError('inventory repository aggregate is invalid');
parseIsk(page.matchingQuantity);
parseIsk(page.estimatedValue);
if (parseIsk(page.estimatedValue) < 0n) throw new TypeError('inventory repository aggregate is negative');
const end = offset + page.rows.length;
if (end > page.matchingRows) throw new TypeError('inventory repository page exceeds summary');
if (page.rows.length === 0) {
if (offset < page.matchingRows || page.nextOffset !== null) throw new TypeError('inventory repository returned an empty page before the end');
} else if (end < page.matchingRows) {
if (page.nextOffset !== end) throw new TypeError('inventory repository continuation skips or loops');
} else if (page.nextOffset !== null) {
throw new TypeError(`inventory repository returned a continuation after the end (${offset},${page.rows.length},${page.matchingRows},${page.nextOffset})`);
}
} else if (page.rows.length !== 0 || page.matchingRows !== 0 || page.matchingQuantity !== '0' || page.estimatedValue !== null || page.nextOffset !== null || typeof page.reason !== 'string' || !page.reason) {
throw new TypeError('inventory repository unavailable response is invalid');
}
}
function validateReturnedResourcePins(resources: readonly InventoryResourceSnapshot[], authoritative: readonly InventoryResourceSnapshot[]): void {
const authoritativeByKey = new Map(authoritative.map((resource) => [snapshotSelectionKey(resource), resource]));
const seen = new Set<string>();
for (const resource of resources) {
const key = snapshotSelectionKey(resource);
const expected = authoritativeByKey.get(key);
if (!expected || seen.has(key) || !sameSnapshotPins(resource, expected)) throw new TypeError('inventory repository resource pin does not match authoritative snapshot');
seen.add(key);
}
if (seen.size !== authoritativeByKey.size) throw new TypeError('inventory repository resource pin does not match authoritative snapshot');
}
function validateRepositoryRows(rows: readonly InventoryProjectionRow[], resources: readonly InventoryResourceSnapshot[]): void {
const owners = new Map(resources.map((resource) => [resource.characterId, resource]));
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== 'object' || !isDecimalId(row.itemId) || !Number.isSafeInteger(row.characterId) || row.characterId <= 0 || typeof row.itemKey !== 'string' || seen.has(row.itemKey) || !Number.isSafeInteger(row.typeId) || row.typeId <= 0 || !Number.isSafeInteger(row.quantity) || row.quantity < 0) throw new TypeError('inventory repository returned a malformed row');
validateQuantity(row.quantity);
const owner = owners.get(row.characterId);
if (!owner || row.resourceKey !== `character:${row.characterId}` || row.generation !== owner.generation || row.referenceVersion !== owner.referenceVersion || row.priceVersion !== owner.priceVersion || row.enrichmentVersion !== owner.enrichmentVersion) throw new TypeError('inventory repository row pin does not match selected resource');
validateInventoryProjectionRow(row, {
userId: owner.userId,
resource: owner.resource,
authority: { userId: owner.userId, characterId: owner.characterId, grantRevision: owner.grantRevision, authorizationEpoch: owner.authorizationEpoch, credentialVersion: owner.credentialVersion, scopes: owner.grantedScopes },
generation: owner.generation!,
referenceVersion: owner.referenceVersion!,
priceVersion: owner.priceVersion,
enrichmentVersion: owner.enrichmentVersion,
});
seen.add(row.itemKey);
}
}
/** Pure-core query orchestration over a bounded, typed persistence port. */
export class InventoryQueryService {
private readonly repository: InventoryQueryRepository;
private readonly now: () => number;
private readonly cursorTtlMs: number;
private readonly maxPageSize: number;
constructor(options: InventoryQueryServiceOptions);
constructor(repository: InventoryQueryRepository, options?: Omit<InventoryQueryServiceOptions, 'repository'>);
constructor(optionsOrRepository: InventoryQueryServiceOptions | InventoryQueryRepository, extraOptions: Omit<InventoryQueryServiceOptions, 'repository'> = {}) {
const options: InventoryQueryServiceOptions = 'repository' in optionsOrRepository
? optionsOrRepository
: { repository: optionsOrRepository, ...extraOptions };
this.repository = options.repository;
this.now = options.now ?? (() => Date.now());
this.cursorTtlMs = options.cursorTtlMs ?? DEFAULT_INVENTORY_CURSOR_TTL_MS;
this.maxPageSize = options.maxPageSize ?? INVENTORY_QUERY_MAX_PAGE_SIZE;
if (!Number.isSafeInteger(this.cursorTtlMs) || this.cursorTtlMs < 1 || this.cursorTtlMs > MAX_INVENTORY_CURSOR_TTL_MS) throw new RangeError('inventory cursor TTL is invalid');
if (!Number.isSafeInteger(this.maxPageSize) || this.maxPageSize < 1 || this.maxPageSize > INVENTORY_QUERY_MAX_PAGE_SIZE) throw new RangeError('inventory page cap is invalid');
}
async query(input: InventoryQueryRequest): Promise<InventoryQueryResult> {
const now = finiteNow(this.now);
const request = await this.normalize(input);
let resources: readonly InventoryResourceSnapshot[];
try {
resources = await this.repository.authorizedResources(request.callerUserId);
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory resource coverage is unavailable' };
}
this.validateResources(resources, request.callerUserId);
const selected = this.resolveSelection(request, resources);
if (selected === null) return invalidCursorResult('authorization_required', 'Inventory resource selection is not authorized for caller');
const resolved: NormalizedRequest = { ...request, selectedResources: selected };
if (selected.length === 0) return invalidCursorResult('authorization_required', 'No authorized linked character resources are available');
const selectedKeys = new Set(selected.map(resourceSelectionKey));
const selectedSnapshots = canonicalSnapshots(resources.filter((resource) => selectedKeys.has(resourceSelectionKey(resource.resource))));
const failure = selectedSnapshots.find((resource) => resource.status !== 'ready');
if (failure) {
const failureStatus = failure.status === 'ready' ? 'unavailable' : failure.status;
return { status: failureStatus, rows: [], summary: null, reason: failure.reason ?? `Character ${failure.characterId} inventory coverage is unavailable` };
}
const fingerprint = queryFingerprint(resolved, selectedSnapshots);
let offset = 0;
let cursorState: InventoryCursorState | null = null;
if (resolved.cursor) {
try {
cursorState = await this.repository.getCursor(resolved.cursor.token);
} catch {
return invalidCursorResult('unavailable', 'Inventory cursor storage is unavailable');
}
if (!cursorState) return invalidCursorResult('unavailable', 'Inventory cursor is unknown or expired');
try {
validateInventoryCursorState(cursorState);
} catch {
return invalidCursorResult('unavailable', 'Inventory cursor state is malformed');
}
if (cursorState.token !== resolved.cursor.token) return invalidCursorResult('unavailable', 'Inventory cursor token envelope is malformed');
const lookup = cursorLookup(resolved.cursor.token, resolved, selectedSnapshots, fingerprint, now);
const validation = validateInventoryCursorUse(cursorState, lookup);
if (!validation.valid) {
const status: CursorFailure = validation.reason === 'caller_mismatch' || validation.reason === 'resource_mismatch' || validation.reason === 'authorization_mismatch' ? 'authorization_required' : validation.reason === 'input_version_mismatch' || validation.reason === 'expired' || validation.reason === 'exhausted' ? 'stale' : 'unavailable';
return invalidCursorResult(status, `Inventory cursor rejected: ${validation.reason}`);
}
offset = cursorState.position.offset;
}
const pageRequest: InventoryRepositoryPageRequest = { callerUserId: resolved.callerUserId, selectedResources: resolved.selectedResources, filters: resolved.filters, sort: resolved.sort, offset, pageSize: resolved.pageSize };
let page: InventoryRepositoryPage;
try {
page = await this.repository.readPage(pageRequest);
} catch (error) {
if (error instanceof TypeError) throw error;
if (error instanceof RangeError) return { status: 'unavailable', rows: [], summary: null, reason: error.message };
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory query storage is unavailable' };
}
validateRepositoryPage(page, resolved, offset);
validateReturnedResourcePins(page.resources, selectedSnapshots);
if (page.status !== 'ready') return { status: page.status, rows: [], summary: null, reason: page.reason };
if (selectedSnapshots.some((resource) => resource.status !== 'ready')) throw new TypeError('ready repository page contains a non-ready resource');
validateRepositoryRows(page.rows, selectedSnapshots);
const summary = { matchingRows: page.matchingRows, matchingQuantity: page.matchingQuantity, estimatedValue: page.estimatedValue, scope: 'all_matching_rows' as const };
if (page.rows.length === 0 && resolved.cursor) {
if (!cursorState) throw new TypeError('inventory continuation cursor state is missing');
try {
await this.repository.replaceCursor({ ...cursorState, status: 'exhausted' });
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' };
}
const result: InventoryQueryResult = { status: 'ready', rows: [], summary, nextCursor: null, pageState: 'after_end', provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))) };
if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid after-end page');
return result;
}
let nextCursor: InventoryCursor | null = null;
if (page.rows.length > 0) {
const token = randomUUID();
const position = { offset: page.nextOffset ?? offset + page.rows.length, lastItemId: page.rows[page.rows.length - 1]!.itemId };
try {
await this.repository.putCursor({ token, status: 'active', position, pin: cursorPin(resolved, selectedSnapshots, fingerprint, now + this.cursorTtlMs) });
} catch {
return { status: 'unavailable', rows: [], summary: null, reason: 'Inventory cursor storage is unavailable' };
}
nextCursor = { token };
}
const result: InventoryQueryResult = {
status: 'ready',
rows: stripRows(page.rows),
summary,
nextCursor,
pageState: resolved.cursor ? 'continuation' : 'initial',
provenance: provenance(selectedSnapshots, now, versionDigest(selectedSnapshots.map((resource) => resource.priceVersion)), versionDigest(selectedSnapshots.map((resource) => resource.referenceVersion))),
} as InventoryQueryResult;
if (!validateInventoryPageResult(result).valid) throw new TypeError('inventory service produced an invalid page');
return result;
}
execute(input: InventoryQueryRequest): Promise<InventoryQueryResult> {
return this.query(input);
}
private async normalize(input: InventoryQueryRequest): Promise<NormalizedRequest> {
if (!input || typeof input !== 'object' || typeof input.callerUserId !== 'string' || !input.callerUserId) throw new TypeError('inventory caller is required');
const pageSize: unknown = input.pageSize;
if (!Number.isSafeInteger(pageSize) || (pageSize as number) < 1 || (pageSize as number) > this.maxPageSize) throw new RangeError(`inventory page size must be 1..${this.maxPageSize}`);
const boundedPageSize = pageSize as number;
const resourceSelectionExplicit = input.selectedResources !== undefined;
if (resourceSelectionExplicit && (!Array.isArray(input.selectedResources) || input.selectedResources.length > INVENTORY_QUERY_MAX_FILTER_VALUES)) throw new RangeError('selected resource cap exceeded');
return {
callerUserId: input.callerUserId,
selectedResources: resourceSelectionExplicit ? canonicalResources(input.selectedResources!) : [],
resourceSelectionExplicit,
filters: normalizeFilters(input.filters),
sort: normalizeSort(input.sort),
pageSize: boundedPageSize,
cursor: normalizeCursor(input.cursor),
};
}
private validateResources(resources: readonly InventoryResourceSnapshot[], userId: string): void {
if (!Array.isArray(resources) || resources.length > INVENTORY_QUERY_MAX_FILTER_VALUES) throw new RangeError('authorized resource cap exceeded');
const principals = new Set<string>();
for (const resource of resources) {
if (!resource || resource.userId !== userId || resource.userId.length > 256 || !resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.ownerUserId !== userId || !Array.isArray(resource.resource.requiredScopes) || resource.resource.requiredScopes.length > 64 || resource.resource.requiredScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.resource.requiredScopes).size !== resource.resource.requiredScopes.length || !sameScopeOrder(resource.resource.requiredScopes) || !resource.resource.requiredScopes.includes(ESI_ASSETS_SCOPE) || !isDecimalId(resource.resource.principal.id) || !Number.isSafeInteger(Number(resource.resource.principal.id)) || resource.characterId !== Number(resource.resource.principal.id) || principals.has(resource.resource.principal.id)) throw new TypeError('authorized resource repository returned malformed or cross-tenant data');
if (!['ready', 'partial', 'stale', 'authorization_required', 'unavailable'].includes(resource.status)) throw new TypeError('authorized resource status is invalid');
if (!Number.isSafeInteger(resource.grantRevision) || resource.grantRevision < 0 || !Number.isSafeInteger(resource.authorizationEpoch) || resource.authorizationEpoch < 0 || !Number.isSafeInteger(resource.credentialVersion) || resource.credentialVersion < 0 || resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('authorized resource counters are stale or inconsistent');
if (resource.grantedScopes !== undefined && (!Array.isArray(resource.grantedScopes) || resource.grantedScopes.length > 64 || resource.grantedScopes.some((scope: unknown) => typeof scope !== 'string' || !scope || scope.length > 128) || new Set(resource.grantedScopes).size !== resource.grantedScopes.length || !sameScopeOrder(resource.grantedScopes) || !resource.grantedScopes.includes(ESI_ASSETS_SCOPE) || resource.resource.requiredScopes.some((scope: string) => !resource.grantedScopes!.includes(scope)))) throw new TypeError('authorized resource scopes are inconsistent');
if (resource.status === 'ready' && resource.grantedScopes === undefined) throw new TypeError('ready resource is missing current authority scopes');
if (resource.status === 'ready' && (typeof resource.generation !== 'string' || !resource.generation || typeof resource.referenceVersion !== 'string' || !resource.referenceVersion)) throw new TypeError('ready resource is missing an immutable input pin');
if (resource.generation !== null && (typeof resource.generation !== 'string' || !resource.generation || resource.generation.length > 256)) throw new TypeError('resource generation is invalid');
if (resource.referenceVersion !== null && (typeof resource.referenceVersion !== 'string' || !resource.referenceVersion || resource.referenceVersion.length > 256)) throw new TypeError('resource reference pin is invalid');
if (resource.priceVersion !== null && (typeof resource.priceVersion !== 'string' || !resource.priceVersion || resource.priceVersion.length > 256)) throw new TypeError('resource price pin is invalid');
if (resource.enrichmentVersion !== null && (typeof resource.enrichmentVersion !== 'string' || !resource.enrichmentVersion || resource.enrichmentVersion.length > 256)) throw new TypeError('resource enrichment pin is invalid');
if (resource.characterName !== undefined && (typeof resource.characterName !== 'string' || resource.characterName.length > 512)) throw new TypeError('resource character name is invalid');
if (resource.reason !== undefined && (typeof resource.reason !== 'string' || resource.reason.length > 512)) throw new TypeError('resource availability reason is invalid');
if (resource.fetchedAt !== null && !Number.isFinite(resource.fetchedAt)) throw new TypeError('resource fetched timestamp is invalid');
if (resource.upstreamEffectiveAt !== null && !Number.isFinite(resource.upstreamEffectiveAt)) throw new TypeError('resource upstream timestamp is invalid');
principals.add(resource.resource.principal.id);
}
}
private resolveSelection(request: NormalizedRequest, resources: readonly InventoryResourceSnapshot[]): readonly SelectedResource[] | null {
if (!request.resourceSelectionExplicit) return canonicalResources(resources.map((resource) => resource.resource));
if (request.selectedResources.length === 0) return null;
const available = new Map(resources.map((snapshot) => [resourceSelectionKey(snapshot.resource), snapshot.resource]));
const selected = new Map<string, SelectedResource>();
for (const candidate of request.selectedResources) {
const key = resourceSelectionKey(candidate);
if (selected.has(key)) throw new TypeError('inventory resource selection contains duplicate characters');
const authorized = available.get(key);
if (!authorized || !sameResource(candidate, authorized)) return null;
selected.set(key, candidate);
}
return request.selectedResources;
}
}
export async function queryInventory(options: InventoryQueryServiceOptions, request: InventoryQueryRequest): Promise<InventoryQueryResult> {
return new InventoryQueryService(options).query(request);
}
export { normalizeFilters, normalizeSort };
+40 -6
View File
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { access, mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { basename, isAbsolute, join, resolve } from 'node:path';
import { normalizeAll } from './normalize';
import { createReferenceSchema, insertHoboleaksData, insertReferenceData, insertSourceMetadata, insertUniverseData, SCHEMA_VERSION } from './schema';
import { assertReferenceReaderSchema, createReferenceSchema, insertHoboleaksData, insertReferenceData, insertSourceMetadata, insertUniverseData, SCHEMA_VERSION } from './schema';
import { fixtureSources, sha256 } from './sources';
import type {
ActiveManifest,
@@ -26,7 +26,7 @@ export const MANIFEST_FILE = 'manifest.json';
export const DATABASE_DIRECTORY = 'databases';
export const CACHE_DIRECTORY = 'sources';
export const LOCK_DIRECTORY = '.reference-build.lock';
const DEFAULT_IMPORTER_VERSION = `reference-compiler/${SCHEMA_VERSION}-5`;
const DEFAULT_IMPORTER_VERSION = `reference-compiler/${SCHEMA_VERSION}-6`;
const emptyManifest = (): ReferenceManifest => ({ format: 1, sources: {} });
const iso = (date: Date): string => date.toISOString();
@@ -153,7 +153,7 @@ export class ReferenceCompiler {
failureResults = sourceResults;
const attempts: Partial<Record<ReferenceSourceName, SourceAttempt>> = {};
const loaded: Partial<Record<ReferenceSourceName, LoadedSource>> = {};
let changed = !manifest.active || manifest.active.profile !== this.profile || manifest.active.importerVersion !== this.importerVersion;
let changed = !manifest.active || manifest.active.profile !== this.profile || manifest.active.importerVersion !== this.importerVersion || manifest.active.stationCoverage !== this.thresholds.stationCoverage;
const currentFailures: string[] = [];
for (const source of REFERENCE_SOURCES) {
@@ -264,7 +264,7 @@ export class ReferenceCompiler {
return [source, { fingerprint: item.check.fingerprint!, cacheFile: item.cacheFile, localSha256: item.localSha256, canonicalSha256: item.canonicalSha256, archiveByteLength: item.archiveByteLength, checkedAt: item.check.checkedAt, url: item.check.url, etag: item.check.etag, etagIsChecksum: item.check.etagIsChecksum, lastModified: item.check.lastModified, byteLength: item.check.byteLength, importerVersion: this.importerVersion, expectedSha256: item.expectedSha256, sourceRevision: item.check.sourceRevision, sourceBuild: item.check.sourceBuild, stale: item.check.stale } satisfies ActiveSourceInput];
})) as Record<ReferenceSourceName, ActiveSourceInput>;
const committedSources = commitSourceResults(manifest.sources, sourceResults, loaded, this.importerVersion, this.now);
const active: ActiveManifest = { version, database: join(DATABASE_DIRECTORY, `${version}.sqlite`), createdAt: iso(this.now()), profile: this.profile, importerVersion: this.importerVersion, sources: Object.fromEntries(REFERENCE_SOURCES.map((source) => [source, loaded[source]!.check.fingerprint!])) as Record<ReferenceSourceName, string>, sourceInputs, counts };
const active: ActiveManifest = { version, database: join(DATABASE_DIRECTORY, `${version}.sqlite`), createdAt: iso(this.now()), profile: this.profile, importerVersion: this.importerVersion, sources: Object.fromEntries(REFERENCE_SOURCES.map((source) => [source, loaded[source]!.check.fingerprint!])) as Record<ReferenceSourceName, string>, sourceInputs, counts, stationCoverage: this.thresholds.stationCoverage };
const sourceErrors = Object.values(committedSources).filter((result) => result?.status === 'failed').map((result) => `${result!.source}: ${result!.error ?? 'source failed'}`);
const next: ReferenceManifest = { format: 1, active, previous: manifest.active, sources: committedSources, lastAttempt: { startedAt, finishedAt: iso(this.now()), status: 'success', profile: this.profile, version, ...(sourceErrors.length ? { error: sourceErrors.join('; ') } : {}) } };
await atomicWriteJson(this.directory, MANIFEST_FILE, next);
@@ -337,7 +337,9 @@ export class ReferenceCompiler {
try {
const manifest = await readManifest(this.directory);
if (!manifest.active || !manifest.previous) throw new Error('no previous reference artifact is available for rollback');
if (!await exists(artifactDatabasePath(this.directory, manifest.previous.database))) throw new Error('previous reference database is missing');
const previousPath = artifactDatabasePath(this.directory, manifest.previous.database);
if (!await exists(previousPath)) throw new Error('previous reference database is missing');
await validateRollbackArtifact(this.directory, previousPath, manifest.previous, this.profile, this.importerVersion, this.thresholds);
const next: ReferenceManifest = { ...manifest, active: manifest.previous, previous: manifest.active, lastAttempt: { startedAt: iso(this.now()), finishedAt: iso(this.now()), status: 'success', profile: manifest.previous.profile, version: manifest.previous.version } };
await atomicWriteJson(this.directory, MANIFEST_FILE, next);
return { activeVersion: next.active!.version, previousVersion: next.previous?.version };
@@ -345,6 +347,32 @@ export class ReferenceCompiler {
}
}
async function validateRollbackArtifact(directory: string, path: string, artifact: ActiveManifest, expectedProfile: BuildProfile, expectedImporterVersion: string, thresholds: ValidationThresholds): Promise<void> {
if (artifact.profile !== expectedProfile || artifact.importerVersion !== expectedImporterVersion || artifact.stationCoverage !== thresholds.stationCoverage || basename(path) !== `${artifact.version}.sqlite`) throw new Error('previous reference artifact is incompatible with the current compiler/profile');
if (!artifact.sourceInputs || !artifact.counts) throw new Error('previous reference artifact is missing compatibility metadata');
let db: Database | undefined;
try {
for (const source of REFERENCE_SOURCES) {
const expected = artifact.sourceInputs[source];
if (!expected) throw new Error(`previous reference artifact is missing source input metadata for ${source}`);
await readSourceCache(directory, source, expected.cacheFile, cacheExpectation(source, expected, expectedImporterVersion, expectedProfile));
}
db = new Database(path, { readonly: true, strict: true });
const counts = validateCandidate(db, thresholds, artifact.profile);
for (const key of Object.keys(counts)) {
if (!(key in artifact.counts)) throw new Error(`previous reference artifact is missing count metadata for ${key}`);
const expected = artifact.counts[key];
if (counts[key] !== expected) throw new Error(`previous reference artifact count mismatch for ${key}: expected ${expected}, got ${counts[key]}`);
}
const rows = db.query<{ source: string; fingerprint: string; local_sha256: string }, []>('SELECT source,fingerprint,local_sha256 FROM source_provenance').all();
const bySource = new Map(rows.map((row) => [row.source, row]));
for (const source of REFERENCE_SOURCES) {
const expected = artifact.sourceInputs[source]; const row = bySource.get(source);
if (!row || row.fingerprint !== expected.fingerprint || row.local_sha256 !== expected.localSha256) throw new Error(`previous reference artifact provenance mismatch for ${source}`);
}
} finally { try { db?.close(); } catch { /* best effort */ } }
}
function verifyPayload(check: SourceCheck, payload: SourcePayload): void {
if (payload.source !== check.source) throw new Error(`loaded source mismatch: expected ${check.source}, got ${payload.source}`);
if (payload.check.fingerprint !== check.fingerprint) throw new Error('loaded source fingerprint differs from checked fingerprint');
@@ -492,7 +520,8 @@ function canonicalCounts(input: ReturnType<typeof normalizeAll>): Record<string,
regions: Object.keys(input.universe.regions ?? {}).length,
constellations: Object.keys(input.universe.constellations ?? {}).length,
gates: Object.keys(input.universe.gates ?? {}).length,
stations: 0,
stations: Object.keys(input.universe.stations ?? {}).length + Object.values(input.universe.systems).reduce((count, system) => count + (system.stations ?? []).filter((stationId) => !input.universe.stations?.[String(stationId)]).length, 0),
verified_stations: Object.keys(input.universe.stations ?? {}).length,
unresolved_references: [...Object.values(input.reference.blueprints)].reduce((count, blueprint) => count + Object.values(blueprint.activities).reduce((activityCount, activity) => activityCount + [...Object.values(activity.materials), ...Object.values(activity.products)].filter((row) => !input.reference.types[String(row.type_id)]).length, 0), 0),
};
}
@@ -516,12 +545,17 @@ async function validateExistingArtifact(path: string, input: ReturnType<typeof n
}
export function validateCandidate(db: Database, thresholds: ValidationThresholds, profile: string): Record<string, number> {
const schemaVersion = Number((db.query<{ user_version: number }, []>('PRAGMA user_version').get() as { user_version: number } | null)?.user_version ?? 0);
if (schemaVersion !== SCHEMA_VERSION) throw new Error(`SQLite schema version ${schemaVersion} does not match compiler ${SCHEMA_VERSION}`);
assertReferenceReaderSchema(db);
const integrity = db.query<{ integrity_check: string }, []>('PRAGMA integrity_check').all();
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') throw new Error(`SQLite integrity_check failed: ${JSON.stringify(integrity)}`);
const foreignKeys = db.query<unknown, []>('PRAGMA foreign_key_check').all(); if (foreignKeys.length) throw new Error(`SQLite foreign_key_check failed: ${JSON.stringify(foreignKeys)}`);
const count = (table: string): number => Number((db.query<{ count: number }, []>(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count);
const counts: Record<string, number> = {}; for (const table of ['categories', 'groups', 'types', 'market_groups', 'blueprints', 'activities', 'materials', 'products', 'unresolved_references', 'systems', 'regions', 'constellations', 'gates', 'stations']) counts[table] = count(table);
counts.verified_stations = Number((db.query<{ count: number }, []>('SELECT COUNT(*) AS count FROM stations WHERE source_verified = 1').get() as { count: number }).count);
if (counts.systems < thresholds.systemsMinimum) throw new Error(`${profile} validation requires at least ${thresholds.systemsMinimum} systems, got ${counts.systems}`);
if (counts.verified_stations < thresholds.stationsMinimum) throw new Error(`${profile} validation requires at least ${thresholds.stationsMinimum} verified stations, got ${counts.verified_stations}`);
if (counts.blueprints < thresholds.blueprintsMinimum) throw new Error(`${profile} validation requires at least ${thresholds.blueprintsMinimum} blueprints, got ${counts.blueprints}`);
for (const category of thresholds.requiredCategories) { const present = Number((db.query<{ count: number }, [number]>('SELECT COUNT(*) AS count FROM categories WHERE category_id = ?').get(category) as { count: number }).count); if (!present) throw new Error(`${profile} validation requires category ${category}`); }
db.query('SELECT type_id FROM types WHERE type_id IN (34,35,587)').all(); db.query('SELECT blueprint_type_id FROM blueprints LIMIT 1').all();
+30 -1
View File
@@ -289,12 +289,18 @@ export function normalizeUniverse(raw: unknown): EsiUniversePayload {
const constellations = root.constellations === undefined ? undefined : normalizeConstellations(root.constellations);
const regions = root.regions === undefined ? undefined : normalizeRegions(root.regions);
const gates = root.gates === undefined ? undefined : normalizeGates(root.gates);
const stations = root.stations === undefined ? undefined : normalizeStations(root.stations);
for (const row of Object.values(systems)) if (constellations && !constellations[String(row.constellation_id)]) throw new Error(`ESI system ${row.system_id} references missing constellation ${row.constellation_id}`);
for (const row of Object.values(systems)) if (gates) for (const gateId of row.stargates ?? []) {
const gate = gates[String(gateId)];
if (!gate || gate.system_id !== row.system_id) throw new Error(`ESI system ${row.system_id} references missing or mismatched gate ${gateId}`);
}
return { systems, constellations, regions, gates };
for (const row of Object.values(stations ?? {})) if (!systems[String(row.system_id)]) throw new Error(`station ${row.station_id} references missing system ${row.system_id}`);
for (const system of Object.values(systems)) for (const stationId of system.stations ?? []) {
const station = stations?.[String(stationId)];
if (station && station.system_id !== system.system_id) throw new Error(`system ${system.system_id} lists station ${stationId} owned by system ${station.system_id}`);
}
return { systems, constellations, regions, gates, stations };
}
function sourceName(value: unknown, label: string): string {
if (typeof value === 'string' && value) return value;
@@ -324,6 +330,26 @@ function normalizeGates(input: unknown): NonNullable<EsiUniversePayload['gates']
}
return output;
}
function normalizeStations(input: unknown): NonNullable<EsiUniversePayload['stations']> {
const output: NonNullable<EsiUniversePayload['stations']> = {};
for (const [key, entry] of Object.entries(record(input, 'NPC stations'))) {
const row = record(entry, `NPC station ${key}`);
const position = row.position === undefined ? undefined : record(row.position, `NPC station ${key}.position`);
output[key] = {
station_id: idKey(key, row.station_id, `NPC station ${key}.station_id`),
system_id: integer(row.system_id, `NPC station ${key}.system_id`)!,
name: row.name === undefined || row.name === null ? undefined : sourceName(row.name, `NPC station ${key}.name`),
type_id: integer(row.type_id, `NPC station ${key}.type_id`, true),
owner_id: integer(row.owner_id, `NPC station ${key}.owner_id`, true),
position: position === undefined ? undefined : {
x: finite(position.x, `NPC station ${key}.position.x`)!,
y: finite(position.y, `NPC station ${key}.position.y`)!,
z: finite(position.z, `NPC station ${key}.position.z`)!,
},
};
}
return output;
}
export function normalizeAll(reference: unknown, hoboleaks: unknown, universe: unknown): NormalizedInputs {
const normalized = { reference: normalizeReference(reference), hoboleaks: normalizeHoboleaks(hoboleaks), universe: normalizeUniverse(universe) };
@@ -345,4 +371,7 @@ export function validateCrossSource(data: NormalizedInputs): void {
for (const gate of Object.values(data.universe.gates ?? {})) {
if (!systems.has(gate.system_id) || !systems.has(gate.destination_system_id)) throw new Error(`ESI gate ${gate.stargate_id} references missing system`);
}
for (const station of Object.values(data.universe.stations ?? {})) {
if (!systems.has(station.system_id)) throw new Error(`NPC station ${station.station_id} references missing system ${station.system_id}`);
}
}
+13 -4
View File
@@ -302,9 +302,9 @@ async function consumeLines(stream: ReadableStream<Uint8Array>, label: string, o
async function ccpUniverse(path: string): Promise<EsiUniversePayload> {
const { file, entries } = await zipEntries(path);
try {
const required = ['mapSolarSystems.jsonl', 'mapConstellations.jsonl', 'mapRegions.jsonl', 'mapStargates.jsonl'];
const required = ['mapSolarSystems.jsonl', 'mapConstellations.jsonl', 'mapRegions.jsonl', 'mapStargates.jsonl', 'npcStations.jsonl'];
for (const name of required) if (!entries.has(name)) throw new Error(`CCP SDE ZIP is missing ${name}`);
const systems: NonNullable<EsiUniversePayload['systems']> = {}; const constellations: NonNullable<EsiUniversePayload['constellations']> = {}; const regions: NonNullable<EsiUniversePayload['regions']> = {}; const gates: NonNullable<EsiUniversePayload['gates']> = {};
const systems: NonNullable<EsiUniversePayload['systems']> = {}; const constellations: NonNullable<EsiUniversePayload['constellations']> = {}; const regions: NonNullable<EsiUniversePayload['regions']> = {}; const gates: NonNullable<EsiUniversePayload['gates']> = {}; const stations: NonNullable<EsiUniversePayload['stations']> = {};
const read = async (name: string, handler: (value: AnyMap, line: number) => void) => {
const entry = entries.get(name)!; if (entry.directory) throw new Error(`${name} is a directory`); const stream = await zipMemberStream(file, entry); const stats = await consumeLines(stream, name, (value, line) => handler(object(value, `${name}:${line}`), line), ARCHIVE_LIMITS.memberBytes); if (stats.byteLength !== entry.uncompressedSize) throw new Error(`${name} decompressed size ${stats.byteLength} does not match ZIP metadata ${entry.uncompressedSize}`); if (stats.crc32 !== entry.crc32) throw new Error(`${name} CRC-32 does not match ZIP metadata`);
};
@@ -312,10 +312,19 @@ async function ccpUniverse(path: string): Promise<EsiUniversePayload> {
await read('mapConstellations.jsonl', (row, line) => { const key = id(row._key, `mapConstellations:${line}._key`); if (Object.prototype.hasOwnProperty.call(constellations, String(key))) throw new Error(`mapConstellations:${line} duplicate _key ${key}`); constellations[String(key)] = { constellation_id: key, name: sourceName(row.name, `mapConstellations:${line}.name`), region_id: id(row.regionID, `mapConstellations:${line}.regionID`) }; });
await read('mapSolarSystems.jsonl', (row, line) => {
const key = id(row._key, `mapSolarSystems:${line}._key`); const position = object(row.position, `mapSolarSystems:${line}.position`); const stargates = row.stargateIDs === undefined ? undefined : (Array.isArray(row.stargateIDs) ? row.stargateIDs.map((value: unknown, index: number) => id(value, `mapSolarSystems:${line}.stargateIDs[${index}]`)) : (() => { throw new Error(`mapSolarSystems:${line}.stargateIDs must be an array`); })());
if (Object.prototype.hasOwnProperty.call(systems, String(key))) throw new Error(`mapSolarSystems:${line} duplicate _key ${key}`); systems[String(key)] = { system_id: key, name: sourceName(row.name, `mapSolarSystems:${line}.name`), constellation_id: id(row.constellationID, `mapSolarSystems:${line}.constellationID`), position: { x: number(position.x, `mapSolarSystems:${line}.position.x`), y: number(position.y, `mapSolarSystems:${line}.position.y`), z: number(position.z, `mapSolarSystems:${line}.position.z`) }, security_status: number(row.securityStatus, `mapSolarSystems:${line}.securityStatus`), security_class: row.securityClass === undefined || row.securityClass === null ? row.securityClass : String(row.securityClass), stargates };
const stationIDs = row.stationIDs === undefined ? undefined : (Array.isArray(row.stationIDs) ? row.stationIDs.map((value: unknown, index: number) => id(value, `mapSolarSystems:${line}.stationIDs[${index}]`)) : (() => { throw new Error(`mapSolarSystems:${line}.stationIDs must be an array`); })());
if (Object.prototype.hasOwnProperty.call(systems, String(key))) throw new Error(`mapSolarSystems:${line} duplicate _key ${key}`); systems[String(key)] = { system_id: key, name: sourceName(row.name, `mapSolarSystems:${line}.name`), constellation_id: id(row.constellationID, `mapSolarSystems:${line}.constellationID`), position: { x: number(position.x, `mapSolarSystems:${line}.position.x`), y: number(position.y, `mapSolarSystems:${line}.position.y`), z: number(position.z, `mapSolarSystems:${line}.position.z`) }, security_status: number(row.securityStatus, `mapSolarSystems:${line}.securityStatus`), security_class: row.securityClass === undefined || row.securityClass === null ? row.securityClass : String(row.securityClass), stargates, stations: stationIDs };
});
await read('mapStargates.jsonl', (row, line) => { const key = id(row._key, `mapStargates:${line}._key`); if (Object.prototype.hasOwnProperty.call(gates, String(key))) throw new Error(`mapStargates:${line} duplicate _key ${key}`); const destination = object(row.destination, `mapStargates:${line}.destination`); gates[String(key)] = { stargate_id: key, system_id: id(row.solarSystemID, `mapStargates:${line}.solarSystemID`), destination_system_id: id(destination.solarSystemID, `mapStargates:${line}.destination.solarSystemID`) }; });
return { systems, constellations, regions, gates };
await read('npcStations.jsonl', (row, line) => {
const key = id(row._key, `npcStations:${line}._key`); if (Object.prototype.hasOwnProperty.call(stations, String(key))) throw new Error(`npcStations:${line} duplicate _key ${key}`);
const rawPosition = object(row.position, `npcStations:${line}.position`);
const position = { x: number(rawPosition.x, `npcStations:${line}.position.x`), y: number(rawPosition.y, `npcStations:${line}.position.y`), z: number(rawPosition.z, `npcStations:${line}.position.z`) };
// The official CCP member has no station-name field. Names are accepted
// only by separate, explicitly selected adapters; never infer one here.
stations[String(key)] = { station_id: key, system_id: id(row.solarSystemID, `npcStations:${line}.solarSystemID`), type_id: id(row.typeID, `npcStations:${line}.typeID`), owner_id: id(row.ownerID, `npcStations:${line}.ownerID`), position };
});
return { systems, constellations, regions, gates, stations };
} finally { await file.close(); }
}
+140 -9
View File
@@ -1,12 +1,56 @@
import { Database } from 'bun:sqlite';
import { existsSync, readFileSync } from 'node:fs';
import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
import type { InventoryType, ReferenceRepository } from '../../domain/model';
import type { BuildProfile, ReferenceManifest, ReferenceSnapshot } from './types';
import type { InventoryType } from '../../domain/model';
import type {
BuildProfile,
ReferenceCategoryRecord,
ReferenceConstellationRecord,
ReferenceGroupRecord,
ReferenceGroupSelection,
ReferenceIdSelection,
ReferenceListOptions,
ReferenceManifest,
ReferenceReader,
ReferenceRegionRecord,
ReferenceSearchOptions,
ReferenceSnapshot,
ReferenceStationRecord,
ReferenceStationSelection,
ReferenceSystemRecord,
ReferenceSystemSelection,
ReferenceTypeRecord,
} from './types';
import { MANIFEST_FILE, DATABASE_DIRECTORY } from './compiler';
import { assertReferenceReaderSchema } from './schema';
const MAX_IN_BATCH = 500;
type TypeRow = { type_id: number; name_json: string; category_id: number; volume: number | null };
const MAX_READER_LIMIT = 500;
const MAX_READER_OFFSET = 100_000;
type TypeRow = { type_id: number; name_json: string; category_id: number; group_id: number; volume: number | null };
type SearchTypeRow = { type_id: number; name: string; category_id: number; group_id: number; published: number | null; volume: number | null };
type CategoryRow = { category_id: number; name_json: string; published: number | null };
type GroupRow = { group_id: number; category_id: number; name_json: string; published: number | null };
type RegionRow = { region_id: number; name: string | null };
type ConstellationRow = { constellation_id: number; name: string | null; region_id: number | null };
type SystemRow = { system_id: number; name: string; constellation_id: number; region_id: number | null; x: number; y: number; z: number; security_status: number; security_class: string | null };
type StationRow = { station_id: number; system_id: number; system_name: string | null; region_id: number | null; name: string | null; type_id: number | null; owner_id: number | null; x: number | null; y: number | null; z: number | null; source_verified: number };
type ListSelection = ReferenceListOptions;
type IdSelection = ReferenceIdSelection;
type GroupSelection = ReferenceGroupSelection;
type SystemSelection = ReferenceSystemSelection;
type StationSelection = ReferenceStationSelection;
const normalize = (value: string): string => value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase('en-US');
const ftsQuery = (value: string): string => normalize(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((token) => `${token}*`).join(' AND ');
const nameFromJson = (value: string): string => { const names = JSON.parse(value) as Record<string, string>; return names.en ?? Object.values(names)[0] ?? ''; };
function bounded(value: number | undefined, fallback: number): number { return Math.max(0, Math.min(MAX_READER_OFFSET, Number.isSafeInteger(value) ? value! : fallback)); }
function limit(value: number | undefined, fallback = 100): number { return Math.max(1, Math.min(MAX_READER_LIMIT, Number.isSafeInteger(value) ? value! : fallback)); }
function idsFrom(selection: IdSelection): number[] | undefined { if (Array.isArray(selection)) return [...new Set(selection.filter((id) => Number.isSafeInteger(id) && id >= 0))]; if (!selection || selection.ids === undefined) return undefined; if (!Array.isArray(selection.ids)) return []; return [...new Set(selection.ids.filter((id) => Number.isSafeInteger(id) && id >= 0))]; }
function optionValue<T extends object>(selection: IdSelection, key: string): unknown { return !Array.isArray(selection) && selection && key in selection ? (selection as Record<string, unknown>)[key] : undefined; }
function like(value: string): string { return `%${normalize(value).replace(/[\\%_]/g, (part) => `\\${part}`)}%`; }
function assertSelectionBounded(ids: number[] | undefined, label: string): void { if (ids && ids.length > MAX_IN_BATCH) throw new Error(`${label} selection exceeds ${MAX_IN_BATCH} IDs`); }
function inClause(ids: number[], column: string): { sql: string; args: number[] } { return { sql: `${column} IN (${ids.map(() => '?').join(',')})`, args: ids }; }
function assertArtifactPath(root: string, database: string): string {
if (!database || isAbsolute(database) || database.includes('\\') || database.split('/').includes('..')) throw new Error('active reference database path is not a safe relative artifact path');
@@ -18,7 +62,7 @@ function assertArtifactPath(root: string, database: string): string {
}
/** Runtime reader: a read-only handle pinned to one immutable manifest artifact. */
export class SqliteReferenceRepository implements ReferenceRepository, ReferenceSnapshot {
export class SqliteReferenceRepository implements ReferenceReader, ReferenceSnapshot {
private readonly db: Database;
private closed = false;
readonly pinnedVersion: string;
@@ -31,6 +75,7 @@ export class SqliteReferenceRepository implements ReferenceRepository, Reference
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ReferenceManifest;
if (manifest.format !== 1 || !manifest.active) throw new Error(`reference manifest has no active artifact: ${manifestPath}`);
if (expectedProfile && manifest.active.profile !== expectedProfile) throw new Error(`reference artifact must use the ${expectedProfile} profile`);
if (manifest.active.stationCoverage !== 'incomplete' && manifest.active.stationCoverage !== 'measured') throw new Error('reference artifact is missing station coverage profile metadata');
this.pinnedVersion = manifest.active.version;
this.databasePath = assertArtifactPath(root, manifest.active.database);
if (basename(this.databasePath) !== `${this.pinnedVersion}.sqlite`) throw new Error('active reference database filename does not match its manifest version');
@@ -44,6 +89,15 @@ export class SqliteReferenceRepository implements ReferenceRepository, Reference
snapshot(): SqliteReferenceSnapshot { return this.pin(); }
async types(ids: number[]): Promise<InventoryType[]> { this.assertOpen(); return queryTypes(this.db, ids); }
/** Indexed normalized-name search. Empty queries are rejected to avoid accidental full-catalog scans. */
async searchTypes(options: ReferenceSearchOptions): Promise<ReferenceTypeRecord[]> { this.assertOpen(); return queryTypesSearch(this.db, options); }
async searchTypesByName(query: string, options: Omit<ReferenceSearchOptions, 'query'> = {}): Promise<ReferenceTypeRecord[]> { return this.searchTypes({ ...options, query }); }
async categories(selection?: IdSelection): Promise<ReferenceCategoryRecord[]> { this.assertOpen(); return queryCategories(this.db, selection); }
async groups(selection?: GroupSelection): Promise<ReferenceGroupRecord[]> { this.assertOpen(); return queryGroups(this.db, selection); }
async regions(selection?: IdSelection): Promise<ReferenceRegionRecord[]> { this.assertOpen(); return queryRegions(this.db, selection); }
async constellations(selection?: IdSelection): Promise<ReferenceConstellationRecord[]> { this.assertOpen(); return queryConstellations(this.db, selection); }
async systems(selection?: SystemSelection): Promise<ReferenceSystemRecord[]> { this.assertOpen(); return querySystems(this.db, selection); }
async stations(selection?: StationSelection): Promise<ReferenceStationRecord[]> { this.assertOpen(); return queryStations(this.db, selection); }
async version(): Promise<string> { this.assertOpen(); return this.pinnedVersion; }
close(): void { if (!this.closed) { this.closed = true; this.db.close(); } }
[Symbol.dispose](): void { this.close(); }
@@ -58,16 +112,93 @@ export class SqliteReferenceSnapshot implements ReferenceSnapshot {
assertReaderSchema(this.db);
}
async types(ids: number[]): Promise<InventoryType[]> { this.assertOpen(); return queryTypes(this.db, ids); }
async searchTypes(options: ReferenceSearchOptions): Promise<ReferenceTypeRecord[]> { this.assertOpen(); return queryTypesSearch(this.db, options); }
async searchTypesByName(query: string, options: Omit<ReferenceSearchOptions, 'query'> = {}): Promise<ReferenceTypeRecord[]> { return this.searchTypes({ ...options, query }); }
async categories(selection?: IdSelection): Promise<ReferenceCategoryRecord[]> { this.assertOpen(); return queryCategories(this.db, selection); }
async groups(selection?: GroupSelection): Promise<ReferenceGroupRecord[]> { this.assertOpen(); return queryGroups(this.db, selection); }
async regions(selection?: IdSelection): Promise<ReferenceRegionRecord[]> { this.assertOpen(); return queryRegions(this.db, selection); }
async constellations(selection?: IdSelection): Promise<ReferenceConstellationRecord[]> { this.assertOpen(); return queryConstellations(this.db, selection); }
async systems(selection?: SystemSelection): Promise<ReferenceSystemRecord[]> { this.assertOpen(); return querySystems(this.db, selection); }
async stations(selection?: StationSelection): Promise<ReferenceStationRecord[]> { this.assertOpen(); return queryStations(this.db, selection); }
async version(): Promise<string> { this.assertOpen(); return this.pinnedVersion; }
close(): void { if (!this.closed) { this.closed = true; this.db.close(); } }
[Symbol.dispose](): void { this.close(); }
private assertOpen(): void { if (this.closed) throw new Error('reference snapshot is closed'); }
}
/** Cheap metadata check only; full integrity_check belongs to the compiler. */
function queryTypesSearch(db: Database, options: ReferenceSearchOptions): Promise<ReferenceTypeRecord[]> {
const query = typeof options.query === 'string' ? options.query.trim() : '';
const match = ftsQuery(query);
if (!match) return Promise.reject(new Error('type search query must contain letters or numbers'));
const conditions = ['s.type_id IN (SELECT CAST(type_id AS INTEGER) FROM type_search_fts WHERE type_search_fts MATCH ?)']; const args: (string | number)[] = [match];
if (options.categoryId !== undefined) { conditions.push('t.category_id = ?'); args.push(options.categoryId); }
if (options.groupId !== undefined) { conditions.push('t.group_id = ?'); args.push(options.groupId); }
if (options.published !== undefined) { conditions.push('t.published = ?'); args.push(options.published ? 1 : 0); }
const rows = db.query<SearchTypeRow, (string | number)[]>(`SELECT t.type_id, COALESCE((SELECT name FROM type_search WHERE type_id = t.type_id AND language = 'en' LIMIT 1), MIN(s.name)) AS name, t.category_id, t.group_id, t.published, t.volume FROM type_search s JOIN types t ON t.type_id = s.type_id WHERE ${conditions.join(' AND ')} GROUP BY t.type_id ORDER BY s.normalized_name, t.type_id LIMIT ? OFFSET ?`).all(...args, limit(options.limit, 50), bounded(options.offset, 0));
return Promise.resolve(rows.map((row) => ({ id: row.type_id, name: row.name, categoryId: row.category_id, groupId: row.group_id, ...(row.published === null ? {} : { published: row.published === 1 }), ...(row.volume === null ? {} : { volume: row.volume }) })));
}
function queryCategories(db: Database, selection: IdSelection): Promise<ReferenceCategoryRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'category');
if (ids) { const clause = inClause(ids, 'category_id'); conditions.push(clause.sql); args.push(...clause.args); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("name_json LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<CategoryRow, (string | number)[]>(`SELECT category_id,name_json,published FROM categories${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY category_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.category_id, name: nameFromJson(row.name_json), ...(row.published === null ? {} : { published: row.published === 1 }) })));
}
function queryGroups(db: Database, selection: GroupSelection): Promise<ReferenceGroupRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'group');
if (ids) { const clause = inClause(ids, 'group_id'); conditions.push(clause.sql); args.push(...clause.args); }
const categoryId = optionValue(selection, 'categoryId'); if (typeof categoryId === 'number') { conditions.push('category_id = ?'); args.push(categoryId); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("name_json LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<GroupRow, (string | number)[]>(`SELECT group_id,category_id,name_json,published FROM groups${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY group_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.group_id, categoryId: row.category_id, name: nameFromJson(row.name_json), ...(row.published === null ? {} : { published: row.published === 1 }) })));
}
function queryRegions(db: Database, selection: IdSelection): Promise<ReferenceRegionRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'region');
if (ids) { const clause = inClause(ids, 'region_id'); conditions.push(clause.sql); args.push(...clause.args); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("name LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<RegionRow, (string | number)[]>(`SELECT region_id,name FROM regions${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY region_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.region_id, name: row.name })));
}
function queryConstellations(db: Database, selection: IdSelection): Promise<ReferenceConstellationRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'constellation');
if (ids) { const clause = inClause(ids, 'constellation_id'); conditions.push(clause.sql); args.push(...clause.args); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("name LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<ConstellationRow, (string | number)[]>(`SELECT constellation_id,name,region_id FROM constellations${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY constellation_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.constellation_id, name: row.name, regionId: row.region_id })));
}
function querySystems(db: Database, selection: SystemSelection): Promise<ReferenceSystemRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'system');
if (ids) { const clause = inClause(ids, 's.system_id'); conditions.push(clause.sql); args.push(...clause.args); }
const constellationId = optionValue(selection, 'constellationId'); if (typeof constellationId === 'number') { conditions.push('s.constellation_id = ?'); args.push(constellationId); }
const regionId = optionValue(selection, 'regionId'); if (typeof regionId === 'number') { conditions.push('c.region_id = ?'); args.push(regionId); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("s.name LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<SystemRow, (string | number)[]>(`SELECT s.system_id,s.name,s.constellation_id,c.region_id,s.x,s.y,s.z,s.security_status,s.security_class FROM systems s LEFT JOIN constellations c ON c.constellation_id = s.constellation_id${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY s.system_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.system_id, name: row.name, constellationId: row.constellation_id, regionId: row.region_id, x: row.x, y: row.y, z: row.z, securityStatus: row.security_status, securityClass: row.security_class })));
}
function queryStations(db: Database, selection: StationSelection): Promise<ReferenceStationRecord[]> {
const conditions: string[] = []; const args: (string | number)[] = []; const ids = idsFrom(selection);
if (ids && !ids.length) return Promise.resolve([]);
assertSelectionBounded(ids, 'station');
if (ids) { const clause = inClause(ids, 'st.station_id'); conditions.push(clause.sql); args.push(...clause.args); }
const systemId = optionValue(selection, 'systemId'); if (typeof systemId === 'number') { conditions.push('st.system_id = ?'); args.push(systemId); }
const query = optionValue(selection, 'query'); if (typeof query === 'string' && query.trim()) { conditions.push("st.name LIKE ? ESCAPE '\\'"); args.push(like(query)); }
const rows = db.query<StationRow, (string | number)[]>(`SELECT st.station_id,st.system_id,s.name AS system_name,c.region_id,st.name,st.type_id,st.owner_id,st.x,st.y,st.z,st.source_verified FROM stations st JOIN systems s ON s.system_id = st.system_id LEFT JOIN constellations c ON c.constellation_id = s.constellation_id${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY st.station_id LIMIT ? OFFSET ?`).all(...args, limit(Number(optionValue(selection, 'limit'))), bounded(Number(optionValue(selection, 'offset')), 0));
return Promise.resolve(rows.map((row) => ({ id: row.station_id, systemId: row.system_id, systemName: row.system_name, regionId: row.region_id, evidence: row.source_verified === 1 ? 'verified' as const : 'system_listed' as const, sourceVerified: row.source_verified === 1, name: row.name, typeId: row.type_id, ownerId: row.owner_id, x: row.x, y: row.y, z: row.z })));
}
/** Reader construction closes malformed handles before surfacing the schema error. */
function assertReaderSchema(db: Database): void {
const row = db.query<{ present: number }, []>("SELECT COUNT(*) AS present FROM sqlite_master WHERE type='table' AND name IN ('types','categories')").get();
if (!row || Number(row.present) !== 2) { db.close(); throw new Error('active reference database has an unexpected schema'); }
try { assertReferenceReaderSchema(db); } catch (error) { db.close(); throw error; }
}
async function queryTypes(db: Database, requested: number[]): Promise<InventoryType[]> {
@@ -79,12 +210,12 @@ async function queryTypes(db: Database, requested: number[]): Promise<InventoryT
for (let offset = 0; offset < unique.length; offset += MAX_IN_BATCH) {
const batch = unique.slice(offset, offset + MAX_IN_BATCH);
const placeholders = batch.map(() => '?').join(',');
rows.push(...db.query<TypeRow, number[]>(`SELECT type_id,name_json,category_id,volume FROM types WHERE type_id IN (${placeholders})`).all(...batch));
rows.push(...db.query<TypeRow, number[]>(`SELECT type_id,name_json,category_id,group_id,volume FROM types WHERE type_id IN (${placeholders})`).all(...batch));
}
const byId = new Map(rows.map((row) => [row.type_id, row]));
return unique.flatMap((id) => {
const row = byId.get(id); if (!row) return [];
const names = JSON.parse(row.name_json) as Record<string, string>;
return [{ id: row.type_id, name: names.en ?? Object.values(names)[0] ?? String(row.type_id), categoryId: row.category_id, ...(row.volume === null ? {} : { volume: row.volume }) }];
return [{ id: row.type_id, name: names.en ?? Object.values(names)[0] ?? String(row.type_id), categoryId: row.category_id, groupId: row.group_id, ...(row.volume === null ? {} : { volume: row.volume }) }];
});
}
+51 -4
View File
@@ -9,13 +9,14 @@ import type {
ReferenceType,
} from './types';
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 3;
/**
* The database is built from scratch and opened read-only by the serving side.
* Foreign keys are deliberately explicit even for currently empty future tables.
*/
export function createReferenceSchema(db: Database): void {
db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
db.run('PRAGMA foreign_keys = ON');
db.run('PRAGMA journal_mode = DELETE');
db.run('PRAGMA synchronous = FULL');
@@ -56,6 +57,13 @@ export function createReferenceSchema(db: Database): void {
radius REAL,
base_price REAL
);
CREATE TABLE type_search (
type_id INTEGER NOT NULL REFERENCES types(type_id),
language TEXT NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
PRIMARY KEY (type_id, language)
);
CREATE TABLE market_groups (
market_group_id INTEGER PRIMARY KEY,
parent_group_id INTEGER REFERENCES market_groups(market_group_id),
@@ -161,7 +169,8 @@ export function createReferenceSchema(db: Database): void {
owner_id INTEGER,
x REAL,
y REAL,
z REAL
z REAL,
source_verified INTEGER NOT NULL CHECK (source_verified IN (0, 1))
);
CREATE TABLE source_provenance (
source TEXT PRIMARY KEY,
@@ -176,6 +185,8 @@ export function createReferenceSchema(db: Database): void {
CREATE INDEX types_group_idx ON types(group_id);
CREATE INDEX types_category_idx ON types(category_id);
CREATE INDEX types_name_idx ON types(name_json);
CREATE INDEX type_search_normalized_name_idx ON type_search(normalized_name);
CREATE VIRTUAL TABLE type_search_fts USING fts5(type_id UNINDEXED, language UNINDEXED, name, normalized_name);
CREATE INDEX market_groups_parent_idx ON market_groups(parent_group_id);
CREATE INDEX activities_blueprint_idx ON activities(blueprint_type_id);
CREATE INDEX materials_type_idx ON materials(type_id);
@@ -191,11 +202,36 @@ export function createReferenceSchema(db: Database): void {
CREATE INDEX systems_name_idx ON systems(name);
CREATE INDEX gates_destination_idx ON gates(destination_system_id);
CREATE INDEX stations_system_idx ON stations(system_id);
CREATE INDEX stations_source_verified_idx ON stations(source_verified, station_id);
`);
}
/** Validate the complete read schema before a runtime handle or rollback can use an artifact. */
export function assertReferenceReaderSchema(db: Database): void {
const version = Number((db.query<{ user_version: number }, []>('PRAGMA user_version').get() as { user_version: number } | null)?.user_version ?? 0);
const row = db.query<{ present: number }, []>("SELECT COUNT(*) AS present FROM sqlite_master WHERE type='table' AND name IN ('types','type_search','type_search_fts','categories','groups','regions','constellations','systems','stations')").get();
const indexes = db.query<{ present: number }, []>("SELECT COUNT(*) AS present FROM sqlite_master WHERE type='index' AND name IN ('type_search_normalized_name_idx','stations_source_verified_idx')").get();
const requiredColumns: Record<string, string[]> = {
types: ['type_id', 'group_id', 'category_id', 'name_json', 'published', 'volume'],
type_search: ['type_id', 'language', 'name', 'normalized_name'],
type_search_fts: ['type_id', 'language', 'name', 'normalized_name'],
categories: ['category_id', 'name_json', 'published'],
groups: ['group_id', 'category_id', 'name_json', 'published'],
regions: ['region_id', 'name'],
constellations: ['constellation_id', 'region_id', 'name'],
stations: ['station_id', 'system_id', 'name', 'type_id', 'owner_id', 'x', 'y', 'z', 'source_verified'],
systems: ['system_id', 'constellation_id', 'name', 'x', 'y', 'z', 'security_status', 'security_class'],
};
const columnsValid = Object.entries(requiredColumns).every(([table, columns]) => {
const present = new Set(db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map((column) => column.name));
return columns.every((column) => present.has(column));
});
if (version !== SCHEMA_VERSION || !row || Number(row.present) !== 9 || !indexes || Number(indexes.present) !== 2 || !columnsValid) throw new Error(`active reference database has an incompatible schema version (expected ${SCHEMA_VERSION})`);
}
const bool = (value: boolean | undefined): number | null => value === undefined ? null : value ? 1 : 0;
const json = (value: unknown): string => JSON.stringify(value);
const normalizedName = (value: string): string => value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase('en-US');
const keyId = (key: string, label: string): number => {
const id = Number(key);
if (!Number.isSafeInteger(id) || id < 0) throw new Error(`${label} key is not a safe integer: ${key}`);
@@ -229,6 +265,9 @@ export function insertReferenceData(db: Database, data: ReferenceDataPayload): v
type.run(id, row.group_id, row.category_id, json(row.name), row.description === undefined ? null : json(row.description), bool(row.published), bool(row.is_blueprint), bool(row.is_skill), bool(row.is_ore), bool(row.is_repackable), row.volume ?? null, row.packaged_volume ?? null, row.portion_size ?? null, row.mass ?? null, row.capacity ?? null, row.radius ?? null, row.base_price ?? null);
}
insertTypeMaps(db, data.types);
const typeSearch = db.prepare('INSERT INTO type_search(type_id,language,name,normalized_name) VALUES (?,?,?,?)');
const typeSearchFts = db.prepare('INSERT INTO type_search_fts(type_id,language,name,normalized_name) VALUES (?,?,?,?)');
for (const row of Object.values(data.types)) for (const [language, name] of Object.entries(row.name)) { typeSearch.run(row.type_id, language, name, normalizedName(name)); typeSearchFts.run(row.type_id, language, name, normalizedName(name)); }
const marketGroup = db.prepare('INSERT INTO market_groups(market_group_id,parent_group_id,name_json,description_json,icon_id,has_types) VALUES (?,?,?,?,?,?)');
for (const [key, value] of Object.entries(data.market_groups ?? {})) {
const row = value as Record<string, unknown>;
@@ -279,8 +318,16 @@ export function insertUniverseData(db: Database, data: EsiUniversePayload): void
}
const gate = db.prepare('INSERT INTO gates(system_id,stargate_id,destination_system_id) VALUES (?,?,?)');
for (const row of Object.values(data.gates ?? {})) gate.run(row.system_id, row.stargate_id, row.destination_system_id);
const station = db.prepare('INSERT INTO stations(station_id,system_id,name,type_id,owner_id,x,y,z) VALUES (?,?,?,?,?,?,?,?)');
for (const row of Object.values(data.systems)) for (const stationId of row.stations ?? []) station.run(stationId, row.system_id, null, null, null, null, null, null);
const station = db.prepare('INSERT INTO stations(station_id,system_id,name,type_id,owner_id,x,y,z,source_verified) VALUES (?,?,?,?,?,?,?,?,?)');
const stationIds = new Set<number>();
for (const row of Object.values(data.stations ?? {})) {
const position = row.position;
station.run(row.station_id, row.system_id, row.name ?? null, row.type_id ?? null, row.owner_id ?? null, position?.x ?? null, position?.y ?? null, position?.z ?? null, 1);
stationIds.add(row.station_id);
}
// ESI system responses expose station IDs but not station metadata. Preserve
// those IDs as explicitly unknown rather than fabricating a name or type.
for (const row of Object.values(data.systems)) for (const stationId of row.stations ?? []) if (!stationIds.has(stationId)) station.run(stationId, row.system_id, null, null, null, null, null, null, 0);
}
export function insertHoboleaksData(db: Database, data: HoboleaksPayload): void {
+30 -4
View File
@@ -1,4 +1,12 @@
import type { ReferenceRepository } from '../../domain/model';
import type { ReferenceReaderHandle } from '../../domain/reference';
export type {
ReferenceRepository, ReferenceCategoryRecord, ReferenceGroupRecord,
ReferenceTypeRecord, ReferenceRegionRecord, ReferenceConstellationRecord,
ReferenceSystemRecord, ReferenceStationEvidence, ReferenceStationRecord,
ReferenceSearchOptions, ReferenceListOptions, ReferenceIdSelection,
ReferenceGroupSelection, ReferenceSystemSelection, ReferenceStationSelection,
ReferenceReader,
} from '../../domain/reference';
export const REFERENCE_SOURCES = ['reference-data', 'hoboleaks', 'esi-universe'] as const;
export type ReferenceSourceName = (typeof REFERENCE_SOURCES)[number];
@@ -121,12 +129,24 @@ export interface UniverseGate {
destination_system_id: number;
}
export interface ReferenceStation {
station_id: number;
system_id: number;
/** A station name is absent when the selected source only proves the ID. */
name?: string;
type_id?: number;
owner_id?: number;
position?: { x: number; y: number; z: number };
}
export interface EsiUniversePayload {
systems: Record<string, EsiUniverseSystem>;
/** Optional ESI/SDE complements, kept separate from the ESI system contract. */
constellations?: Record<string, { constellation_id: number; name?: string; region_id?: number }>;
regions?: Record<string, { region_id: number; name?: string }>;
gates?: Record<string, UniverseGate>;
/** Verified NPC station records, when the selected universe source provides them. */
stations?: Record<string, ReferenceStation>;
}
export interface SourceCheck {
@@ -175,14 +195,18 @@ export interface ProductionArchiveSourceOptions {
}
export type BuildProfile = 'fixture' | 'production';
/** Station rows are present but not completeness-verified until a measured release replay is captured. */
export type StationCoverageProfile = 'incomplete' | 'measured';
export interface ValidationThresholds {
systemsMinimum: number;
stationsMinimum: number;
stationCoverage: StationCoverageProfile;
requiredCategories: number[];
blueprintsMinimum: number;
}
export const PROFILE_THRESHOLDS: Record<BuildProfile, ValidationThresholds> = {
fixture: { systemsMinimum: 1, requiredCategories: [4, 6, 9], blueprintsMinimum: 1 },
production: { systemsMinimum: 5001, requiredCategories: [4, 6, 9], blueprintsMinimum: 1 },
fixture: { systemsMinimum: 1, stationsMinimum: 0, stationCoverage: 'incomplete', requiredCategories: [4, 6, 9], blueprintsMinimum: 1 },
production: { systemsMinimum: 5001, stationsMinimum: 1, stationCoverage: 'incomplete', requiredCategories: [4, 6, 9], blueprintsMinimum: 1 },
};
export interface ReferenceCompilerOptions {
@@ -232,6 +256,8 @@ export interface ActiveManifest {
/** Content-addressed source cache selected by this artifact. */
sourceInputs: Record<ReferenceSourceName, ActiveSourceInput>;
counts: Record<string, number>;
/** Completeness is explicit; production remains incomplete until measured replay evidence exists. */
stationCoverage?: StationCoverageProfile;
}
export interface ActiveSourceInput {
fingerprint: string;
@@ -281,7 +307,7 @@ export interface SourceCheckResult {
error?: string;
}
export interface ReferenceSnapshot extends ReferenceRepository {
export interface ReferenceSnapshot extends ReferenceReaderHandle {
close(): void;
readonly pinnedVersion: string;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Deployment-time rollout controls. These are deliberately separate from the
* domain capability contracts: a capability may have a complete contract and
* still remain disabled in a particular release.
*
* The allowlist is evaluated on the server for every entry point. Unknown
* names are rejected instead of becoming an accidental opt-in, and newly
* added known capabilities are absent from the baseline allowlist until an
* operator explicitly enables them.
*/
export const RELEASE_CAPABILITIES = [
'asset_search',
'asset_valuation',
'asset_sync',
'assistant_dispatch',
'session_token',
'identity',
'subscriptions',
'wallet_balance',
'inventory_export',
'manufacturing_opportunities',
'inventory_locations',
] as const;
export type ReleaseCapability = (typeof RELEASE_CAPABILITIES)[number];
/** Existing first-slice behavior remains enabled unless an operator narrows it. */
export const BASELINE_RELEASE_CAPABILITIES: readonly ReleaseCapability[] = [
'asset_search',
'asset_valuation',
'asset_sync',
'assistant_dispatch',
'session_token',
'identity',
'subscriptions',
];
const known = new Set<string>(RELEASE_CAPABILITIES);
export class CapabilityConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'CapabilityConfigurationError';
}
}
/**
* Parse APP_CAPABILITY_ALLOWLIST without accepting typos or duplicate policy
* entries. An explicitly empty value disables every capability; an omitted
* value preserves the existing private asset/sign-in baseline.
*/
export function parseCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] {
if (raw === undefined) return [...BASELINE_RELEASE_CAPABILITIES];
const value = raw.trim();
if (value === '') return [];
const result: ReleaseCapability[] = [];
const seen = new Set<string>();
for (const token of value.split(',')) {
const name = token.trim();
if (!name || !known.has(name)) {
throw new CapabilityConfigurationError(`Unknown deployment capability: ${name || '<empty>'}`);
}
if (!seen.has(name)) {
seen.add(name);
result.push(name as ReleaseCapability);
}
}
return result;
}
/** Backend/Convex policy must be explicitly propagated; omission fails closed. */
export function parseBackendCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] {
if (raw === undefined) throw new CapabilityConfigurationError('APP_CAPABILITY_ALLOWLIST is required in the backend deployment');
return parseCapabilityAllowlist(raw);
}
export function capabilityEnabled(
capability: ReleaseCapability,
allowlist: readonly ReleaseCapability[],
): boolean {
return allowlist.includes(capability);
}
export function capabilityDisabledReason(capability: ReleaseCapability): string {
return `The ${capability} capability is disabled by deployment configuration.`;
}
/** A non-throwing check is useful at HTTP boundaries that should fail closed. */
export function requireCapability(
capability: ReleaseCapability,
allowlist: readonly ReleaseCapability[],
): void {
if (!capabilityEnabled(capability, allowlist)) {
throw new CapabilityConfigurationError(capabilityDisabledReason(capability));
}
}
/** Release identifiers are metadata, not a source of authorization. */
export function validReleaseId(value: string): boolean {
return /^(?!latest$)[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
}
/**
* Production images must carry a non-placeholder immutable identifier. Local
* development and fixture runs intentionally use `local`.
*/
export function releaseIdIsImmutable(value: string, environment = process.env.NODE_ENV): boolean {
if (!validReleaseId(value)) return false;
if (environment !== 'production') return true;
return value !== 'local' && value !== 'dev' && value !== 'development' && value !== 'unreleased';
}
+36
View File
@@ -0,0 +1,36 @@
export const RESTORE_MARKER_CONFIRMATION = 'DISPOSABLE_LOOPBACK_ONLY';
export const SYNTHETIC_CHARACTER_ID_MIN = 4_000_000_000;
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
export type RestoreMarkerPhase = 'verify' | 'write' | 'expect-invalidated';
export function parseRestoreMarkerPhase(raw: string | undefined): RestoreMarkerPhase {
if (raw === undefined || raw === '') return 'verify';
if (raw === 'write' || raw === 'verify' || raw === 'expect-invalidated') return raw;
throw new Error('RESTORE_MARKER_PHASE must be verify, write, or expect-invalidated');
}
export function validateRestoreMarkerTarget(raw: string | undefined): URL {
if (!raw) throw new Error('CONVEX_SELF_HOSTED_URL is required');
let target: URL;
try {
target = new URL(raw);
} catch {
throw new Error('CONVEX_SELF_HOSTED_URL must be a valid disposable loopback URL');
}
if (target.protocol !== 'http:' || !LOOPBACK_HOSTS.has(target.hostname)) {
throw new Error('CONVEX_SELF_HOSTED_URL must use an http loopback target (127.0.0.1, localhost, or ::1)');
}
return target;
}
export function validateRestoreMarkerWrite(input: {
phase: RestoreMarkerPhase;
allowWrite: string | undefined;
confirmation: string | undefined;
}): void {
if (input.phase !== 'write') return;
if (input.allowWrite !== '1' || input.confirmation !== RESTORE_MARKER_CONFIRMATION) {
throw new Error(`Writing requires SMOKE_ALLOW_WRITE=1 and RESTORE_MARKER_CONFIRMATION=${RESTORE_MARKER_CONFIRMATION}`);
}
}
+14 -2
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { SsoDiagnosticError } from './oauth-diagnostics';
import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities';
import {
createRemoteJWKSet,
customFetch,
@@ -183,9 +184,20 @@ export class EveSso {
this.verificationKey = options.verificationKey;
}
async authorizationUrl(state: string, challenge: string): Promise<string> {
async authorizationUrl(
state: string,
challenge: string,
requestedScopes: readonly string[] = [ASSET_SCOPE],
): Promise<string> {
nonEmptyString(state, 'state');
nonEmptyString(challenge, 'PKCE challenge');
if (
requestedScopes.length === 0 ||
requestedScopes.some((scope) => typeof scope !== 'string' || !SERVER_SCOPE_ALLOWLIST.includes(scope) || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) ||
new Set(requestedScopes).size !== requestedScopes.length
) {
invalid('Invalid EVE SSO requested scopes');
}
const discovery = await this.discovery();
if (!discovery.codeChallengeMethods.includes('S256')) {
invalid('EVE SSO does not support PKCE S256');
@@ -195,7 +207,7 @@ export class EveSso {
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.clientId);
url.searchParams.set('redirect_uri', this.redirectUri);
url.searchParams.set('scope', ASSET_SCOPE);
url.searchParams.set('scope', requestedScopes.join(' '));
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
+310 -13
View File
@@ -5,7 +5,9 @@ import type {
Character,
CharacterAssetRepository,
UserId,
SafeSyncJob,
} from "$lib/domain/model";
import type { EnrichedLocationNode, ReferenceEnrichmentPin } from "$lib/domain/inventory-location";
export type OAuthStateRecord = {
stateHash: string;
@@ -14,6 +16,12 @@ export type OAuthStateRecord = {
encryptedVerifier: string;
expiresAt: number;
returnTo: string;
intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect';
capability: string;
requestedScopes: string[];
approvedExistingScopes: string[];
initiatingSessionHash?: string;
targetCharacterId?: number;
};
export type CompleteLoginInput = {
@@ -26,6 +34,12 @@ export type CompleteLoginInput = {
tokenExpiresAt: number;
sessionHash: string;
sessionExpiresAt: number;
intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect';
capability: string;
requestedScopes: string[];
approvedExistingScopes: string[];
initiatingSessionHash?: string;
targetCharacterId?: number;
};
export type TokenRecord = {
@@ -35,6 +49,14 @@ export type TokenRecord = {
scopes: string[];
};
export type SavedTokenAuthority = {
saved: true;
credentialVersion: number;
grantRevision: number;
authorizationEpoch: number;
scopes: string[];
};
export type SaveInteractionInput = {
requestId: string;
message: string;
@@ -47,11 +69,54 @@ export type SavedInteraction = SaveInteractionInput & {
created: boolean;
};
export type ClaimedSyncJob = SafeSyncJob & {
jobId: string;
leaseToken: string;
userId: string;
characterId: number;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
scopes: string[];
generation?: string;
cooldownGroup?: string;
};
export type RefreshLease = {
leaseToken: string;
expiresAt: number;
expectedVersion: number;
encryptedTokens: string;
tokenExpiresAt: number;
scopes: string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
};
type AssetPage = {
page: Asset[];
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";
@@ -63,6 +128,11 @@ const QUERY = {
tokenRecord: "functions:bridgeTokenRecord",
characters: "functions:bridgeCharacters",
assetsPage: "functions:bridgeAssetsPage",
syncJob: "functions:bridgeSyncJob",
cooldown: "functions:bridgeCooldown",
workerCooldown: "functions:bridgeSyncWorkerCooldown",
enrichmentManifest: "functions:bridgeEnrichmentManifest",
enrichmentChunks: "functions:bridgeEnrichmentChunksPage",
} as const;
const MUTATION = {
@@ -76,6 +146,29 @@ const MUTATION = {
finishSync: "functions:bridgeFinishSync",
failSync: "functions:bridgeFailSync",
saveInteraction: "functions:bridgeSaveInteraction",
setPrimary: "functions:bridgeSetPrimary",
invalidateAuthorization: "functions:bridgeInvalidateAuthorization",
quarantineAuthorization: "functions:bridgeQuarantineAuthorization",
enqueueAssetSync: "functions:bridgeEnqueueAssetSync",
cancelSyncJob: "functions:bridgeCancelSyncJob",
claimSyncJob: "functions:bridgeClaimSyncJob",
renewSyncJob: "functions:bridgeRenewSyncJob",
beginSyncJob: "functions:bridgeBeginSyncJob",
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",
commitRefresh: "functions:bridgeCommitRefresh",
markRefreshUncertain: "functions:bridgeMarkRefreshUncertain",
markRefreshDispatched: "functions:bridgeMarkRefreshDispatched",
} as const;
function queryReference(name: string): any {
@@ -87,28 +180,37 @@ function mutationReference(name: string): any {
}
/**
* Server-side Convex repository. Every call goes through a public bridge
* function carrying the dedicated bridge secret; this adapter never uses a
* deployment admin key and is not safe to import into browser code.
* Server-side Convex repository. Web calls use the general bridge secret;
* worker lifecycle calls use a separate narrowly-scoped worker secret. This
* adapter never uses a deployment admin key and is not safe to import into
* browser code.
*/
export class ConvexStore implements CharacterAssetRepository {
private readonly client: ConvexHttpClient;
private readonly bridgeSecret: string;
private readonly bridgeSecret?: string;
private readonly workerSecret?: string;
public constructor(url: string, bridgeSecret: string) {
public constructor(url: string, bridgeSecret?: string, workerSecret?: string) {
if (!url) throw new Error("Convex URL is required");
if (!bridgeSecret) throw new Error("Convex bridge secret is required");
if (!bridgeSecret && !workerSecret) throw new Error("Convex bridge or worker secret is required");
this.bridgeSecret = bridgeSecret;
this.workerSecret = workerSecret;
this.client = new ConvexHttpClient(url, {
skipConvexDeploymentUrlCheck: true,
logger: false,
});
this.bridgeSecret = bridgeSecret;
}
private bridgeArgs(args: Record<string, unknown>): Record<string, unknown> {
if (!this.bridgeSecret) throw new Error("General bridge authority is unavailable to this store");
return { ...args, bridgeSecret: this.bridgeSecret };
}
private workerArgs(args: Record<string, unknown>): Record<string, unknown> {
if (!this.workerSecret) throw new Error("Worker bridge authority is unavailable to this store");
return { ...args, workerSecret: this.workerSecret };
}
private async query<T>(name: string, args: Record<string, unknown>): Promise<T> {
return (await this.client.query(
queryReference(name),
@@ -124,6 +226,21 @@ export class ConvexStore implements CharacterAssetRepository {
)) as T;
}
private async workerMutation<T>(name: string, args: Record<string, unknown>): Promise<T> {
return (await this.client.mutation(
mutationReference(name),
this.workerArgs(args),
{ skipQueue: true },
)) as T;
}
private async workerQuery<T>(name: string, args: Record<string, unknown>): Promise<T> {
return (await this.client.query(
queryReference(name),
this.workerArgs(args),
)) as T;
}
public async session(hash: string): Promise<{ userId: string; sessionId: string } | null> {
return await this.query<{ userId: string; sessionId: string } | null>(QUERY.session, {
sessionHash: hash,
@@ -141,6 +258,12 @@ export class ConvexStore implements CharacterAssetRepository {
encryptedVerifier: string;
expiresAt: number;
returnTo: string;
intent: OAuthStateRecord['intent'];
capability: string;
requestedScopes: string[];
approvedExistingScopes: string[];
initiatingSessionHash?: string;
targetCharacterId?: number;
}): Promise<void> {
await this.mutation(MUTATION.createOAuth, input);
}
@@ -156,16 +279,30 @@ export class ConvexStore implements CharacterAssetRepository {
}
public async completeLogin(input: CompleteLoginInput): Promise<{ userId: string; sessionId: string }> {
return await this.mutation<{ userId: string; sessionId: string }>(
const result = await this.mutation<{ userId: string; sessionId: string; rejected?: string }>(
MUTATION.completeLogin,
input,
);
if (result.rejected === 'ownership_conflict') throw new Error('Character ownership conflict');
return result;
}
public async tokenRecord(userId: string, characterId: number): Promise<TokenRecord | null> {
return await this.query<TokenRecord | null>(QUERY.tokenRecord, { userId, characterId });
}
public async setPrimary(userId: string, characterId: number, initiatingSessionHash: string): Promise<void> {
await this.mutation(MUTATION.setPrimary, { userId, characterId, initiatingSessionHash });
}
public async invalidateAuthorization(userId: string, characterId: number, initiatingSessionHash: string): Promise<void> {
await this.mutation(MUTATION.invalidateAuthorization, { userId, characterId, initiatingSessionHash });
}
public async quarantineAuthorization(userId: string, characterId: number): Promise<void> {
await this.mutation(MUTATION.quarantineAuthorization, { userId, characterId });
}
public async saveTokens(
userId: string,
characterId: number,
@@ -175,12 +312,15 @@ export class ConvexStore implements CharacterAssetRepository {
scopes: string[];
expectedVersion: number;
},
): Promise<boolean> {
return await this.mutation<boolean>(MUTATION.saveTokens, {
): Promise<SavedTokenAuthority | false> {
const result = await this.mutation<boolean | SavedTokenAuthority>(MUTATION.saveTokens, {
userId,
characterId,
...input,
returnAuthority: true,
});
if (result === true) throw new Error("Token save did not return committed authority");
return result;
}
public async characters(userId: UserId): Promise<Character[]> {
@@ -190,6 +330,12 @@ export class ConvexStore implements CharacterAssetRepository {
userId: row.userId,
name: row.name,
scopes: row.scopes,
loginRole: row.loginRole,
authorizationStatus: row.authorizationStatus,
grantRevision: row.grantRevision,
credentialVersion: row.credentialVersion,
authorizationEpoch: row.authorizationEpoch,
datasets: row.datasetStatuses,
assets: {
status: row.state,
...(row.syncedAt === undefined ? {} : { syncedAt: row.syncedAt }),
@@ -206,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.
@@ -219,9 +367,12 @@ export class ConvexStore implements CharacterAssetRepository {
args.expectedGeneration = pinnedGeneration;
}
const result: AssetPage = await this.query<AssetPage>(QUERY.assetsPage, args);
if (result.snapshotChanged) throw new Error(SNAPSHOT_CHANGED);
if (pinnedGeneration === undefined) {
pinnedGeneration = result.generation;
} else if (result.generation !== pinnedGeneration) {
pinnedEnrichmentStatus = result.enrichmentStatus;
pinnedEnrichmentVersion = result.enrichmentVersion;
} else if (result.generation !== pinnedGeneration || result.enrichmentStatus !== pinnedEnrichmentStatus || result.enrichmentVersion !== pinnedEnrichmentVersion) {
throw new Error(SNAPSHOT_CHANGED);
}
assets.push(...result.page);
@@ -242,8 +393,154 @@ export class ConvexStore implements CharacterAssetRepository {
throw new Error(SNAPSHOT_CHANGED);
}
public async beginSync(userId: string, characterId: number): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, { userId, characterId });
public async enqueueAssetSync(userId: string, characterId: number, initiatingSessionHash: string): Promise<SafeSyncJob> {
return await this.mutation<SafeSyncJob>(MUTATION.enqueueAssetSync, { userId, characterId, initiatingSessionHash });
}
public async syncJob(userId: string, jobId: string): Promise<SafeSyncJob | null> {
return await this.query<SafeSyncJob | null>(QUERY.syncJob, { userId, jobId });
}
public async cancelSyncJob(userId: string, jobId: string, initiatingSessionHash: string): Promise<SafeSyncJob | null> {
return await this.mutation<SafeSyncJob | null>(MUTATION.cancelSyncJob, { userId, jobId, initiatingSessionHash });
}
public async claimSyncJob(workerId: string, leaseMs?: number): Promise<ClaimedSyncJob | null> {
return await this.workerMutation<ClaimedSyncJob | null>(MUTATION.claimSyncJob, { workerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
}
public async renewSyncJob(jobId: string, leaseToken: string, workerId: string, leaseMs?: number): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.renewSyncJob, { jobId, leaseToken, workerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
}
public async beginSyncJob(jobId: string, leaseToken: string): Promise<string> {
return await this.workerMutation<string>(MUTATION.beginSyncJob, { jobId, leaseToken });
}
public async refreshSyncJobAuthority(jobId: string, leaseToken: string, expected: { grantRevision: number; authorizationEpoch: number; credentialVersion: number; scopes: string[] }): Promise<{ grantRevision: number; authorizationEpoch: number; credentialVersion: number; scopes: string[] }> {
return await this.workerMutation(MUTATION.refreshSyncJobAuthority, { jobId, leaseToken, expectedGrantRevision: expected.grantRevision, expectedAuthorizationEpoch: expected.authorizationEpoch, expectedCredentialVersion: expected.credentialVersion, expectedScopes: expected.scopes });
}
public async restartSyncJobGeneration(jobId: string, leaseToken: string): Promise<void> {
await this.workerMutation(MUTATION.restartSyncJobGeneration, { jobId, leaseToken });
}
public async stageSyncAssets(jobId: string, leaseToken: string, index: number, assets: Asset[]): Promise<void> {
await this.workerMutation(MUTATION.stageSyncAssets, { jobId, leaseToken, index, assets });
}
public async stageSyncEnrichment(jobId: string, leaseToken: string, index: number, rows: EnrichedLocationNode[]): Promise<void> {
await this.workerMutation(MUTATION.stageSyncEnrichment, { jobId, leaseToken, index, rows });
}
public async resetSyncEnrichment(jobId: string, leaseToken: string): Promise<void> {
await this.workerMutation(MUTATION.resetSyncEnrichment, { jobId, leaseToken });
}
public async commitSyncJob(jobId: string, leaseToken: string, chunkCount: number, syncedAt: number, expiresAt: number, enrichment?: { status: 'complete' | 'partial' | 'failed' | 'unavailable'; version?: string; inputPin?: ReferenceEnrichmentPin; chunkCount: number; unresolvedCount: number; error?: string }): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.commitSyncJob, {
jobId, leaseToken, chunkCount, syncedAt, expiresAt,
...(enrichment === undefined ? {} : {
enrichmentStatus: enrichment.status,
...(enrichment.version === undefined ? {} : { enrichmentVersion: enrichment.version }),
...(enrichment.inputPin === undefined ? {} : { enrichmentInputPin: enrichment.inputPin }),
enrichmentChunkCount: enrichment.chunkCount,
enrichmentUnresolvedCount: enrichment.unresolvedCount,
...(enrichment.error === undefined ? {} : { enrichmentError: enrichment.error }),
}),
});
}
public async enrichmentManifest(userId: string, characterId: number, expectedGeneration?: string): Promise<EnrichmentManifest | null> {
return await this.query<EnrichmentManifest | null>(QUERY.enrichmentManifest, { userId, characterId, ...(expectedGeneration === undefined ? {} : { expectedGeneration }) });
}
public async enrichmentChunks(userId: string, characterId: number, generation: string): Promise<EnrichedLocationNode[]> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const rows: EnrichedLocationNode[] = [];
let cursor: string | null = null;
let pinnedStatus: string | null | undefined;
let pinnedVersion: string | null | undefined;
try {
for (let page = 0; page < 100_000; page += 1) {
const result: EnrichmentChunkPage = await this.query<EnrichmentChunkPage>(QUERY.enrichmentChunks, { userId, characterId, expectedGeneration: generation, paginationOpts: { numItems: 8, cursor } });
if (result.generation !== generation) throw new Error(SNAPSHOT_CHANGED);
if (pinnedStatus === undefined) { pinnedStatus = result.status; pinnedVersion = result.version; }
else if (result.status !== pinnedStatus || result.version !== pinnedVersion) throw new Error(SNAPSHOT_CHANGED);
rows.push(...result.chunks.flatMap((chunk: { index: number; rows: EnrichedLocationNode[] }) => chunk.rows));
if (result.isDone) return rows;
if (result.continueCursor === cursor) throw new Error('Convex enrichment pagination did not advance');
cursor = result.continueCursor;
}
throw new Error('Convex enrichment pagination exceeded its safety bound');
} catch (error) {
if (error instanceof Error && error.message === SNAPSHOT_CHANGED && attempt < 2) continue;
throw error;
}
}
throw new Error(SNAPSHOT_CHANGED);
}
public async failSyncJob(jobId: string, leaseToken: string): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.failSyncJob, { jobId, leaseToken });
}
public async deferSyncJob(jobId: string, leaseToken: string, dueAt: number, cooldown?: { context: string; until: number; group?: string }): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.deferSyncJob, { jobId, leaseToken, dueAt, ...(cooldown === undefined ? {} : { cooldownContext: cooldown.context, cooldownUntil: cooldown.until, ...(cooldown.group === undefined ? {} : { cooldownGroup: cooldown.group }) }) });
}
public async cooldown(context: string, group?: string): Promise<{ until: number; group?: string; kind: string } | null> {
return await this.workerQuery<{ until: number; group?: string; kind: string } | null>(QUERY.workerCooldown, { context, ...(group === undefined ? {} : { group }) });
}
public async recordSyncCooldown(context: string, until: number, group?: string): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.recordSyncCooldown, { context, until, ...(group === undefined ? {} : { group }) });
}
public async acquireRefreshLease(userId: string, characterId: number, ownerId: string, leaseMs?: number): Promise<RefreshLease | null> {
return await this.workerMutation<RefreshLease | null>(MUTATION.acquireRefreshLease, { userId, characterId, ownerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
}
public async renewRefreshLease(userId: string, characterId: number, leaseToken: string, ownerId: string, leaseMs?: number): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.renewRefreshLease, { userId, characterId, leaseToken, ownerId, ...(leaseMs === undefined ? {} : { leaseMs }) });
}
public async releaseRefreshLease(userId: string, characterId: number, leaseToken: string, ownerId: string): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.releaseRefreshLease, { userId, characterId, leaseToken, ownerId });
}
public async markRefreshDispatched(userId: string, characterId: number, leaseToken: string, ownerId: string): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.markRefreshDispatched, { userId, characterId, leaseToken, ownerId });
}
public async commitRefresh(userId: string, characterId: number, leaseToken: string, ownerId: string, expectedVersion: number, input: { encryptedTokens: string; tokenExpiresAt: number; scopes: string[]; jobId?: string; jobLeaseToken?: string }): Promise<SavedTokenAuthority | null> {
return await this.workerMutation<SavedTokenAuthority | null>(MUTATION.commitRefresh, { userId, characterId, leaseToken, ownerId, expectedVersion, ...input });
}
public async markRefreshUncertain(userId: string, characterId: number, leaseToken: string, ownerId: string, expectedVersion: number): Promise<boolean> {
return await this.workerMutation<boolean>(MUTATION.markRefreshUncertain, { userId, characterId, leaseToken, ownerId, expectedVersion });
}
public async beginSync(
userId: string,
characterId: number,
expectedAuthority?: {
scopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion?: number;
},
): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, {
userId,
characterId,
...(expectedAuthority === undefined ? {} : {
expectedScopes: [...expectedAuthority.scopes],
expectedGrantRevision: expectedAuthority.grantRevision,
expectedAuthorizationEpoch: expectedAuthority.authorizationEpoch,
...(expectedAuthority.credentialVersion === undefined ? {} : { expectedCredentialVersion: expectedAuthority.credentialVersion }),
}),
});
}
public async stageAssets(
+526
View File
@@ -0,0 +1,526 @@
import { randomUUID } from 'node:crypto';
import type { EsiWalletBalanceObservation } from '../esi';
import { ESI_WALLET_SCOPE, EsiError } from '../esi';
import { parseIsk } from '../../domain/money';
import {
sameWalletFence,
validateWalletAmount,
validateWalletAuthorityFence,
validateWalletSnapshot,
type WalletAuthorityFence,
type WalletBalanceRepository,
type WalletBalanceSnapshot,
type WalletCredential,
type WalletFailurePublication,
type WalletPublicationResult,
type WalletSnapshotPublication,
} from './balance-repository';
export const WALLET_BALANCE_DATASET = 'wallet_balance' as const;
export const DEFAULT_WALLET_JOB_LEASE_MS = 60_000;
export const DEFAULT_WALLET_QUEUE_LIMIT = 100;
export const DEFAULT_WALLET_COOLDOWN_MS = 0;
export type WalletJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined';
/** A serialized, character-scoped job. The fence is the only job authority. */
export type WalletBalanceJob = {
id: string;
dataset: typeof WALLET_BALANCE_DATASET;
owner: { kind: 'user'; userId: string };
authority: 'user';
characterId: number;
fence: WalletAuthorityFence;
status: WalletJobStatus;
attempts: number;
dueAt: number;
createdAt: number;
updatedAt: number;
leaseExpiresAt: number | null;
workerId: string | null;
leaseToken: string | null;
lastError?: string;
};
export type WalletEnqueueRequest = {
userId: string;
characterId: number;
fence: WalletAuthorityFence;
dueAt?: number;
now?: number;
};
export type WalletEnqueueResult =
| { status: 'queued'; job: WalletBalanceJob }
| { status: 'duplicate'; job: WalletBalanceJob }
| { status: 'cooldown'; job: null; dueAt: number }
| { status: 'queue_full'; job: null; dueAt: number };
export type WalletBalanceClaim = WalletBalanceJob & {
status: 'running';
workerId: string;
leaseToken: string;
leaseExpiresAt: number;
};
export interface WalletBalanceJobQueue {
enqueue(request: WalletEnqueueRequest): Promise<WalletEnqueueResult>;
claim(workerId: string, now?: number): Promise<WalletBalanceClaim | null>;
renew(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number, leaseMs?: number): Promise<boolean>;
complete(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, status: 'succeeded' | 'failed' | 'cancelled' | 'quarantined', now?: number, error?: string): Promise<boolean>;
defer(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, dueAt: number, now?: number): Promise<boolean>;
isLive(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number): Promise<boolean>;
/** Returns the canonical stored claim; caller-supplied owner/fence fields are never trusted. */
canonicalClaim(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number): Promise<WalletBalanceClaim | null>;
cooldown(userId: string, characterId: number, now?: number): Promise<{ until: number; group: string } | null>;
setCooldown(userId: string, characterId: number, until: number, group?: string): Promise<void>;
/** Server/adapter clock used by atomic fixture publication; event timestamps are not authority. */
now(): number;
cooldownAfterSuccess(userId: string, characterId: number, now?: number): Promise<void>;
get(jobId: string): Promise<WalletBalanceJob | null>;
}
/** Typed transport shape matching EsiClient.getWalletBalance; no URL or OAuth logic is added. */
export type WalletBalanceTransport = {
getWalletBalance(characterId: number, accessToken: string, authorizationKey: import('../esi').AuthorizationKey, signal?: AbortSignal): Promise<EsiWalletBalanceObservation>;
};
/** A repository/credential backend was unavailable; this is not an auth decision. */
export class WalletAuthorityUnavailableError extends Error {
public readonly code = 'authority_unavailable';
public readonly retryable = true;
public constructor(message = 'wallet authority backend is unavailable') {
super(message);
this.name = 'WalletAuthorityUnavailableError';
}
}
/** A worker lease was lost while remote I/O was in flight; publication is forbidden. */
export class WalletLeaseLostError extends Error {
public readonly code = 'lease_lost';
public constructor() {
super('wallet worker lease was lost before publication');
this.name = 'WalletLeaseLostError';
}
}
/** Reads current authority and an already-issued credential; it cannot grant scopes. */
export type WalletBalanceAuthorityPort = Pick<WalletBalanceRepository, 'currentAuthority' | 'credential'>;
/** Atomic publication seam for a durable queue/store adapter. */
export type WalletBalancePublicationPort = {
publishSuccess(input: WalletSnapshotPublication): Promise<WalletPublicationResult>;
publishFailure(input: WalletFailurePublication): Promise<WalletPublicationResult>;
};
export type WalletBalanceJobAdapterOptions = {
jobs: WalletBalanceJobQueue;
authority: WalletBalanceAuthorityPort;
publication: WalletBalancePublicationPort;
transport: WalletBalanceTransport;
now?: () => number;
leaseMs?: number;
/** Renew at most this often while transport/publication I/O is pending. */
heartbeatMs?: number;
};
export type WalletJobRunResult =
| { status: 'idle' }
| { status: 'published'; job: WalletBalanceJob; publication: WalletPublicationResult }
| { status: 'rejected'; job: WalletBalanceJob; reason: string }
| { status: 'unavailable'; job: WalletBalanceJob; error: string }
| { status: 'failed'; job: WalletBalanceJob; error: string };
function validNow(value: number): number {
if (!Number.isFinite(value) || value < 0) throw new RangeError('wallet job clock returned an invalid timestamp');
return value;
}
function cloneFence(fence: WalletAuthorityFence): WalletAuthorityFence {
return { ...fence, principal: { ...fence.principal }, scopes: [...fence.scopes] };
}
function validateFence(fence: WalletAuthorityFence): void {
try { validateWalletAuthorityFence(fence); } catch { throw new TypeError('wallet job authority fence is invalid'); }
}
function validateJob(job: WalletBalanceJob): void {
if (!job || typeof job.id !== 'string' || !job.id || job.dataset !== WALLET_BALANCE_DATASET || job.authority !== 'user' || job.owner.kind !== 'user' || job.owner.userId !== job.fence.userId || job.characterId !== job.fence.characterId || !Number.isSafeInteger(job.attempts) || job.attempts < 0 || !Number.isFinite(job.dueAt) || !Number.isFinite(job.createdAt) || !Number.isFinite(job.updatedAt)) throw new TypeError('wallet job is invalid');
validateFence(job.fence);
if (!['queued', 'running', 'succeeded', 'failed', 'cancelled', 'quarantined'].includes(job.status)) throw new TypeError('wallet job status is invalid');
}
function claimMatches(job: WalletBalanceJob | null, claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now: number): job is WalletBalanceClaim {
return !!job && job.status === 'running' && job.id === claim.id && job.workerId === claim.workerId && job.leaseToken === claim.leaseToken && job.leaseExpiresAt !== null && job.leaseExpiresAt > now;
}
/**
* Fixture-only bounded queue. A durable queue must serialize the same dedupe,
* lease, cooldown and publication transitions instead of copying this Map.
*/
export class InMemoryWalletBalanceJobQueue implements WalletBalanceJobQueue {
private readonly jobs = new Map<string, WalletBalanceJob>();
private readonly activeByResource = new Map<string, string>();
private readonly cooldowns = new Map<string, { until: number; group: string }>();
private readonly maxPending: number;
private readonly leaseMs: number;
private readonly cooldownMs: number;
private readonly clock: () => number;
public constructor(options: { maxPending?: number; leaseMs?: number; cooldownMs?: number; now?: () => number } = {}) {
this.maxPending = options.maxPending ?? DEFAULT_WALLET_QUEUE_LIMIT;
this.leaseMs = options.leaseMs ?? DEFAULT_WALLET_JOB_LEASE_MS;
this.cooldownMs = options.cooldownMs ?? DEFAULT_WALLET_COOLDOWN_MS;
this.clock = options.now ?? (() => Date.now());
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1 || !Number.isSafeInteger(this.leaseMs) || this.leaseMs < 1 || !Number.isSafeInteger(this.cooldownMs) || this.cooldownMs < 0) throw new RangeError('wallet queue bounds are invalid');
}
public async enqueue(request: WalletEnqueueRequest): Promise<WalletEnqueueResult> {
validateFence(request.fence);
if (typeof request.userId !== 'string' || !request.userId || request.userId !== request.fence.userId || request.characterId !== request.fence.characterId) throw new TypeError('wallet enqueue identity does not match fence');
const now = validNow(request.now ?? this.now());
const dueAt = request.dueAt === undefined ? now : validNow(request.dueAt);
const key = `${request.userId}\u0000${request.characterId}`;
const cooldown = this.cooldowns.get(key);
if (cooldown && cooldown.until > now) return { status: 'cooldown', job: null, dueAt: cooldown.until };
const activeId = this.activeByResource.get(key);
if (activeId) {
const active = this.jobs.get(activeId);
if (active && (active.status === 'queued' || active.status === 'running')) return { status: 'duplicate', job: structuredClone(active) };
this.activeByResource.delete(key);
}
const pending = [...this.jobs.values()].filter((job) => job.status === 'queued' || job.status === 'running').length;
if (pending >= this.maxPending) return { status: 'queue_full', job: null, dueAt };
const job: WalletBalanceJob = {
id: randomUUID(), dataset: WALLET_BALANCE_DATASET, owner: { kind: 'user', userId: request.userId }, authority: 'user', characterId: request.characterId,
fence: cloneFence(request.fence), status: 'queued', attempts: 0, dueAt, createdAt: now, updatedAt: now, leaseExpiresAt: null, workerId: null, leaseToken: null,
};
this.jobs.set(job.id, job);
this.activeByResource.set(key, job.id);
return { status: 'queued', job: structuredClone(job) };
}
public now(): number { return validNow(this.clock()); }
public async claim(workerId: string, now = this.now()): Promise<WalletBalanceClaim | null> {
if (typeof workerId !== 'string' || !workerId) throw new TypeError('wallet worker ID is invalid');
const at = validNow(now);
for (const job of this.jobs.values()) {
if (job.status === 'running' && (job.leaseExpiresAt === null || job.leaseExpiresAt <= at)) {
job.status = 'failed'; job.updatedAt = at; job.lastError = 'worker lease expired'; job.leaseExpiresAt = null; job.workerId = null; job.leaseToken = null; this.activeByResource.delete(`${job.owner.userId}\u0000${job.characterId}`);
}
}
const candidate = [...this.jobs.values()].filter((job) => job.status === 'queued' && job.dueAt <= at).sort((a, b) => a.dueAt - b.dueAt || a.createdAt - b.createdAt)[0];
if (!candidate) return null;
candidate.status = 'running'; candidate.attempts += 1; candidate.updatedAt = at; candidate.workerId = workerId; candidate.leaseToken = randomUUID(); candidate.leaseExpiresAt = at + this.leaseMs;
return structuredClone(candidate) as WalletBalanceClaim;
}
public async renew(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now(), leaseMs = this.leaseMs): Promise<boolean> {
const at = validNow(now);
const job = this.jobs.get(claim.id) ?? null;
if (!claimMatches(job, claim, at) || !Number.isSafeInteger(leaseMs) || leaseMs < 1) return false;
job.leaseExpiresAt = at + leaseMs; job.updatedAt = at;
return true;
}
public async complete(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, status: Exclude<WalletJobStatus, 'queued' | 'running' | 'succeeded'>, now = this.now(), error?: string): Promise<boolean> {
const at = validNow(now);
const job = this.jobs.get(claim.id) ?? null;
if (!claimMatches(job, claim, at)) return false;
const mutable: WalletBalanceJob = job;
mutable.status = status; mutable.updatedAt = at; mutable.leaseExpiresAt = null; mutable.workerId = null; mutable.leaseToken = null; if (error) mutable.lastError = error;
this.activeByResource.delete(`${mutable.owner.userId}\u0000${mutable.characterId}`);
return true;
}
public async defer(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, dueAt: number, now = this.now()): Promise<boolean> {
const at = validNow(now); const target = validNow(dueAt); const job = this.jobs.get(claim.id) ?? null;
if (!claimMatches(job, claim, at)) return false;
const mutable: WalletBalanceJob = job;
mutable.status = 'queued'; mutable.dueAt = target; mutable.updatedAt = at; mutable.leaseExpiresAt = null; mutable.workerId = null; mutable.leaseToken = null;
return true;
}
public async isLive(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now()): Promise<boolean> {
const at = validNow(now); return claimMatches(this.jobs.get(claim.id) ?? null, claim, at);
}
public async cooldown(userId: string, characterId: number, now = this.now()): Promise<{ until: number; group: string } | null> {
const at = validNow(now); const key = `${userId}\u0000${characterId}`; const value = this.cooldowns.get(key);
if (!value || value.until <= at) return null;
return { ...value };
}
/** Fixture/test hook: defer future automatic enqueues without changing auth. */
public async setCooldown(userId: string, characterId: number, until: number, group = 'wallet'): Promise<void> {
const value = validNow(until);
this.cooldowns.set(`${userId}\u0000${characterId}`, { until: value, group });
}
public async canonicalClaim(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now()): Promise<WalletBalanceClaim | null> {
const at = validNow(now);
const job = this.jobs.get(claim.id) ?? null;
if (!claimMatches(job, claim, at)) return null;
validateJob(job);
return structuredClone(job) as WalletBalanceClaim;
}
public async get(jobId: string): Promise<WalletBalanceJob | null> {
const job = this.jobs.get(jobId); return job ? structuredClone(job) : null;
}
/** Called by the publication adapter after a successful remote read. */
public async cooldownAfterSuccess(userId: string, characterId: number, now = this.now()): Promise<void> {
if (this.cooldownMs > 0) await this.setCooldown(userId, characterId, validNow(now) + this.cooldownMs, 'wallet');
}
}
/**
* Fixture publication adapter: it composes the queue lease fence with the
* repository's atomic authority/publication methods. Durable implementations
* should perform this transition in one serialized checkpoint.
*/
export class InMemoryWalletBalancePublicationPort implements WalletBalancePublicationPort {
public constructor(private readonly jobs: WalletBalanceJobQueue, private readonly repository: WalletBalanceRepository) {}
public async publishSuccess(input: WalletSnapshotPublication): Promise<WalletPublicationResult> {
try { validateWalletAuthorityFence(input.fence); } catch { return { accepted: false, reason: 'borrowed_resource' }; }
const serverNow = this.jobs.now();
const claim = await this.jobs.canonicalClaim({ id: input.jobId, workerId: input.workerId, leaseToken: input.leaseToken }, serverNow);
if (!claim) return { accepted: false, reason: 'expired_job' };
if (claim.characterId !== input.fence.characterId || claim.owner.userId !== input.fence.userId || !sameWalletFence(claim.fence, input.fence)) return { accepted: false, reason: 'borrowed_resource' };
const publication = { ...input, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, publishedAt: serverNow };
let result: WalletPublicationResult;
try { result = await this.repository.publishSnapshot(publication); validatePublicationResult(result, 'success', publication); } catch { await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, 'malformed_publication'); return { accepted: false, reason: 'invalid_snapshot' }; }
if (result.accepted) {
await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'succeeded', serverNow);
await this.jobs.cooldownAfterSuccess(claim.owner.userId, claim.characterId, serverNow);
} else await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, result.reason);
return result;
}
public async publishFailure(input: WalletFailurePublication): Promise<WalletPublicationResult> {
try { validateWalletAuthorityFence(input.fence); } catch { return { accepted: false, reason: 'borrowed_resource' }; }
const serverNow = this.jobs.now();
const claim = await this.jobs.canonicalClaim({ id: input.jobId, workerId: input.workerId, leaseToken: input.leaseToken }, serverNow);
if (!claim) return { accepted: false, reason: 'expired_job' };
if (claim.characterId !== input.fence.characterId || claim.owner.userId !== input.fence.userId || !sameWalletFence(claim.fence, input.fence)) return { accepted: false, reason: 'borrowed_resource' };
const publication = { ...input, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, publishedAt: serverNow };
let result: WalletPublicationResult;
try { result = await this.repository.publishFailure(publication); validatePublicationResult(result, 'failure', publication); } catch { await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, 'malformed_publication'); return { accepted: false, reason: 'invalid_snapshot' }; }
await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, input.failure.category);
if (result.accepted && input.failure.retryAt !== undefined) await this.jobs.setCooldown(claim.owner.userId, claim.characterId, input.failure.retryAt, input.failure.cooldownGroup ?? 'wallet');
return result;
}
}
function validateObservation(observation: EsiWalletBalanceObservation, now: number): void {
if (!observation || typeof observation !== 'object') throw new TypeError('wallet transport response is invalid');
validateWalletAmount(observation.amount);
if (!Number.isFinite(observation.retrievedAt) || observation.retrievedAt < 0 || observation.retrievedAt > now || !Number.isFinite(observation.expiresAt) || observation.expiresAt <= observation.retrievedAt) throw new TypeError('wallet transport timestamps are invalid');
// A transport cannot claim a negative/unknown expiry, but upstream effective
// time remains null because ESI does not provide one for this endpoint.
if (observation.expiresAt <= now) throw new TypeError('wallet transport returned an already expired observation');
}
function exactScopes(left: unknown, right: readonly string[]): boolean {
if (!Array.isArray(left) || left.length !== right.length || left.length === 0 || left.length > 64 || left.some((scope) => typeof scope !== 'string' || !scope || scope.length > 256) || new Set(left).size !== left.length) return false;
return [...left].sort().join('\u0000') === [...right].sort().join('\u0000');
}
function validCredential(value: WalletCredential | null, fence: WalletAuthorityFence): value is WalletCredential {
if (!value || typeof value !== 'object' || typeof value.accessToken !== 'string' || !value.accessToken || !value.authorizationKey || typeof value.authorizationKey !== 'object') return false;
const key = value.authorizationKey;
return key.userId === fence.userId && key.principal === `character:${fence.principal.id}` && key.characterId === fence.characterId && key.grantRevision === fence.grantRevision && key.authorizationEpoch === fence.authorizationEpoch && key.credentialVersion === fence.credentialVersion && exactScopes(key.scopes, fence.scopes);
}
const PUBLICATION_REASONS = new Set(['borrowed_resource', 'stale_grant', 'stale_credential', 'stale_authorization_epoch', 'authorization_revoked', 'mismatched_character', 'invalid_snapshot', 'stale_generation', 'expired_job', 'duplicate_publication']);
function samePublishedSnapshot(left: WalletBalanceSnapshot, right: WalletBalanceSnapshot): boolean {
return left.userId === right.userId && left.characterId === right.characterId && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.amount === right.amount && left.fetchedAt === right.fetchedAt && left.expiresAt === right.expiresAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt && left.generation === right.generation && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion;
}
function validatePublicationResult(value: WalletPublicationResult, expected: 'success' | 'failure', requested: WalletSnapshotPublication | WalletFailurePublication): void {
if (!value || typeof value !== 'object' || typeof value.accepted !== 'boolean') throw new TypeError('wallet publication returned malformed result');
if (value.accepted) {
if (value.outcome !== expected) throw new TypeError('wallet publication outcome does not match request');
if (expected === 'success') {
if (value.snapshot === null) throw new TypeError('successful wallet publication omitted snapshot');
validateWalletSnapshot(value.snapshot);
const input = requested as WalletSnapshotPublication;
if (value.snapshot.userId !== input.fence.userId || value.snapshot.characterId !== input.fence.characterId || value.snapshot.principal.id !== input.fence.principal.id || value.snapshot.authorizationRevision !== input.fence.grantRevision || value.snapshot.authorizationEpoch !== input.fence.authorizationEpoch || value.snapshot.credentialVersion !== input.fence.credentialVersion || !samePublishedSnapshot(value.snapshot, input.snapshot)) throw new TypeError('successful wallet publication snapshot does not match request');
} else if (value.snapshot !== null) throw new TypeError('failure wallet publication returned a snapshot');
} else if (!PUBLICATION_REASONS.has(value.reason)) throw new TypeError('wallet publication rejection is invalid');
}
function failureDetails(error: unknown): { category: string; message: string; retryable: boolean; retryAt?: number; cooldownGroup?: string } {
if (error instanceof EsiError) {
const cooldown = 'cooldown' in error && error.cooldown && typeof error.cooldown === 'object' ? error.cooldown as { until?: unknown; group?: unknown; context?: unknown } : undefined;
const cooldownGroup = typeof cooldown?.group === 'string' ? cooldown.group : typeof cooldown?.context === 'string' ? cooldown.context : undefined;
return { category: error.code, message: error.message, retryable: error.code !== 'authorization_context', ...(typeof cooldown?.until === 'number' && Number.isFinite(cooldown.until) ? { retryAt: cooldown.until } : {}), ...(cooldownGroup === undefined ? {} : { cooldownGroup }) };
}
if (error instanceof Error) return { category: 'refresh_failed', message: 'wallet refresh failed', retryable: true };
return { category: 'refresh_failed', message: 'wallet refresh failed', retryable: true };
}
/** Runs one claimed job and never broadens its OAuth grants. */
export class WalletBalanceJobAdapter {
private readonly now: () => number;
private readonly leaseMs: number;
private readonly heartbeatMs: number;
public constructor(private readonly options: WalletBalanceJobAdapterOptions) {
this.now = options.now ?? (() => Date.now());
this.leaseMs = options.leaseMs ?? DEFAULT_WALLET_JOB_LEASE_MS;
this.heartbeatMs = options.heartbeatMs ?? Math.max(10, Math.floor(this.leaseMs / 3));
if (!Number.isSafeInteger(this.leaseMs) || this.leaseMs < 1 || !Number.isSafeInteger(this.heartbeatMs) || this.heartbeatMs < 1 || this.heartbeatMs >= this.leaseMs) throw new RangeError('wallet job lease/heartbeat is invalid');
}
/** Keep remote I/O inside the current worker lease; a lost lease aborts and forbids publication. */
private async withLease<T>(claim: WalletBalanceClaim, work: (signal: AbortSignal) => Promise<T>, checkAfter = true): Promise<T> {
const controller = new AbortController();
let lost = false;
const renew = async (): Promise<void> => {
try {
const renewed = await this.options.jobs.renew(claim, this.options.jobs.now(), this.leaseMs);
if (!renewed) {
lost = true;
controller.abort();
}
} catch {
lost = true;
controller.abort();
}
};
await renew();
if (lost) throw new WalletLeaseLostError();
const timer = setInterval(() => { void renew(); }, this.heartbeatMs);
try {
const result = await work(controller.signal);
if (lost || (checkAfter && !(await this.options.jobs.isLive(claim, this.options.jobs.now())))) throw new WalletLeaseLostError();
return result;
} finally {
clearInterval(timer);
}
}
private async unavailable(claim: WalletBalanceClaim, error?: unknown): Promise<WalletJobRunResult> {
const at = this.options.jobs.now();
const message = error instanceof WalletAuthorityUnavailableError ? error.message : 'wallet authority backend is unavailable';
try {
await this.options.jobs.defer(claim, at + Math.min(this.leaseMs, 5_000), at);
} catch {
// The original unavailable classification remains safer than quarantining
// a resource when the queue backend itself cannot be reached.
}
return { status: 'unavailable', job: claim, error: message };
}
private async publishRefreshFailure(claim: WalletBalanceClaim, error: unknown): Promise<WalletJobRunResult> {
if (error instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
const details = failureDetails(error);
const failedAt = this.options.jobs.now();
const failure: WalletFailurePublication = {
jobId: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken,
fence: claim.fence,
publishedAt: failedAt,
failure: { userId: claim.owner.userId, characterId: claim.characterId, failedAt, category: details.category, message: details.message, retryable: details.retryable, authorizationRevision: claim.fence.grantRevision, authorizationEpoch: claim.fence.authorizationEpoch, credentialVersion: claim.fence.credentialVersion, generation: `wallet-${claim.id}-${claim.attempts}`, ...(details.retryAt === undefined ? {} : { retryAt: details.retryAt }), ...(details.cooldownGroup === undefined ? {} : { cooldownGroup: details.cooldownGroup }), jobId: claim.id },
};
try {
const publication = await this.withLease(claim, async () => this.options.publication.publishFailure(failure), false);
validatePublicationResult(publication, 'failure', failure);
if (!publication.accepted && publication.reason === 'expired_job') return { status: 'rejected', job: claim, reason: publication.reason };
return { status: 'failed', job: claim, error: details.message };
} catch (publicationError) {
if (publicationError instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
return this.unavailable(claim, publicationError);
}
}
public async runNext(workerId: string): Promise<WalletJobRunResult> {
const at = this.options.jobs.now();
const claim = await this.options.jobs.claim(workerId, at);
if (!claim) return { status: 'idle' };
return this.runClaim(claim);
}
public async runClaim(claim: WalletBalanceClaim): Promise<WalletJobRunResult> {
const startedAt = this.options.jobs.now();
let canonical: WalletBalanceClaim | null;
try {
canonical = await this.options.jobs.canonicalClaim(claim, startedAt);
} catch {
return { status: 'unavailable', job: claim, error: 'wallet job authority is unavailable' };
}
if (!canonical) return { status: 'rejected', job: claim, reason: 'expired_job' };
try { validateJob(canonical); } catch { return this.unavailable(claim, new WalletAuthorityUnavailableError('wallet job response is malformed')); }
// Never use mutable caller-owned owner/character/fence fields after this point.
claim = canonical;
let current: WalletAuthorityFence | null;
try {
current = await this.options.authority.currentAuthority(claim.owner.userId, claim.characterId);
} catch (error) {
// A failed authority read is not evidence of revocation. Leave the job
// queued for a bounded retry instead of quarantining its resource.
return this.unavailable(claim, error);
}
if (current) {
try { validateWalletAuthorityFence(current); } catch (error) { return this.unavailable(claim, new WalletAuthorityUnavailableError('wallet authority response is malformed')); }
}
if (!current || !sameWalletFence(current, claim.fence)) {
const reason = !current ? 'authorization_revoked' : current.grantRevision !== claim.fence.grantRevision ? 'stale_grant' : current.authorizationEpoch !== claim.fence.authorizationEpoch ? 'stale_authorization_epoch' : current.credentialVersion !== claim.fence.credentialVersion ? 'stale_credential' : 'borrowed_resource';
await this.options.jobs.complete(claim, 'quarantined', startedAt, reason);
return { status: 'rejected', job: claim, reason };
}
let credential: WalletCredential | null;
try {
credential = await this.options.authority.credential(claim.fence);
} catch (error) {
return this.unavailable(claim, error);
}
if (!validCredential(credential, claim.fence)) {
await this.options.jobs.complete(claim, 'quarantined', startedAt, 'credential_authority_mismatch');
return { status: 'rejected', job: claim, reason: 'credential_authority_mismatch' };
}
let observation: EsiWalletBalanceObservation;
try {
observation = await this.withLease(claim, (signal) => this.options.transport.getWalletBalance(claim.characterId, credential.accessToken, credential.authorizationKey, signal));
} catch (error) {
return this.publishRefreshFailure(claim, error);
}
const observedAt = this.options.jobs.now();
try {
validateObservation(observation, observedAt);
} catch (error) {
return this.publishRefreshFailure(claim, error);
}
const generation = `wallet-${claim.id}-${claim.attempts}`;
const snapshot: WalletBalanceSnapshot = {
userId: claim.owner.userId,
characterId: claim.characterId,
principal: { ...claim.fence.principal },
amount: observation.amount,
fetchedAt: observation.retrievedAt,
expiresAt: observation.expiresAt,
upstreamEffectiveAt: null,
coverage: { kind: 'snapshot', generation, complete: true },
generation,
authorizationRevision: claim.fence.grantRevision,
authorizationEpoch: claim.fence.authorizationEpoch,
credentialVersion: claim.fence.credentialVersion,
};
const publication: WalletSnapshotPublication = { jobId: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, snapshot, publishedAt: observedAt };
try {
const result = await this.withLease(claim, () => this.options.publication.publishSuccess({ ...publication, publishedAt: validNow(this.now()) }), false);
validatePublicationResult(result, 'success', { ...publication, publishedAt: validNow(this.now()) });
if (!result.accepted) return { status: 'rejected', job: claim, reason: result.reason };
return { status: 'published', job: claim, publication: result };
} catch (error) {
if (error instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
return this.unavailable(claim, error);
}
}
}
export type WalletBalanceJobHandler = WalletBalanceJobAdapter;
+434
View File
@@ -0,0 +1,434 @@
import type { AuthorizationKey } from '../esi';
import { ESI_WALLET_SCOPE } from '../esi';
import type {
AuthorizationStatus,
CompleteReportProvenance,
DatasetCoverage,
ResourcePrincipal,
SelectedResource,
WalletBalanceComponent,
} from '../../domain/model';
import { isDecimalId } from '../../domain/inventory-location';
import { parseIsk } from '../../domain/money';
/** A character resource resolved by the server, never by a browser selector. */
export type WalletAuthorizedResource = {
userId: string;
characterId: number;
characterName?: string;
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
resource: SelectedResource;
authorizationStatus: AuthorizationStatus;
consent: 'granted' | 'missing';
grantedScopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number | null;
};
/** The immutable wallet snapshot kept independently of authorization metadata. */
export type WalletBalanceSnapshot = {
userId: string;
characterId: number;
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
amount: string;
fetchedAt: number;
expiresAt: number;
upstreamEffectiveAt: number | null;
coverage: Extract<DatasetCoverage, { kind: 'snapshot' }>;
authorizationRevision: number;
authorizationEpoch: number;
credentialVersion: number;
generation: string;
};
export type WalletRefreshFailure = {
userId: string;
characterId: number;
failedAt: number;
category: string;
retryable: boolean;
authorizationRevision: number;
authorizationEpoch: number;
credentialVersion: number;
/** Monotonic job attempt identity; content cannot mutate under one generation. */
generation: string;
retryAt?: number;
cooldownGroup?: string;
message?: string;
jobId?: string;
};
/** The complete authority fence copied into a job and checked at publication. */
export type WalletAuthorityFence = {
userId: string;
characterId: number;
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
grantRevision: number;
authorizationEpoch: number;
credentialVersion: number;
scopes: readonly string[];
};
export type WalletCredential = {
accessToken: string;
authorizationKey: AuthorizationKey;
};
export type WalletSnapshotPublication = {
jobId: string;
workerId: string;
leaseToken: string;
fence: WalletAuthorityFence;
snapshot: WalletBalanceSnapshot;
publishedAt: number;
};
export type WalletFailurePublication = {
jobId: string;
workerId: string;
leaseToken: string;
fence: WalletAuthorityFence;
failure: WalletRefreshFailure;
publishedAt: number;
};
export type WalletPublicationRejection =
| 'borrowed_resource'
| 'stale_grant'
| 'stale_credential'
| 'stale_authorization_epoch'
| 'authorization_revoked'
| 'mismatched_character'
| 'invalid_snapshot'
| 'stale_generation'
| 'expired_job'
| 'duplicate_publication';
export type WalletPublicationResult =
| { accepted: true; snapshot: WalletBalanceSnapshot | null; outcome: 'success' | 'failure' }
| { accepted: false; reason: WalletPublicationRejection };
/**
* Persistence/runtime seam owned by a later serialized checkpoint. Implementations
* must make authority checks and snapshot publication atomic. No OAuth behavior is
* defined here; credential() only reads an already-authorized credential.
*/
export interface WalletBalanceRepository {
authorizedResources(userId: string): Promise<readonly WalletAuthorizedResource[]>;
snapshot(userId: string, characterId: number): Promise<WalletBalanceSnapshot | null>;
failure(userId: string, characterId: number): Promise<WalletRefreshFailure | null>;
currentAuthority(userId: string, characterId: number): Promise<WalletAuthorityFence | null>;
credential(fence: WalletAuthorityFence): Promise<WalletCredential | null>;
publishSnapshot(publication: WalletSnapshotPublication): Promise<WalletPublicationResult>;
publishFailure(publication: WalletFailurePublication): Promise<WalletPublicationResult>;
}
/** Compatibility aliases make the feature-local port easy to adopt without changing shared contracts. */
export type WalletRepository = WalletBalanceRepository;
export type WalletResource = WalletAuthorizedResource;
export function walletResourceKey(userId: string, characterId: number): string {
return `${userId}\u0000${characterId}`;
}
export function walletPrincipalKey(principal: ResourcePrincipal): string {
return `${principal.kind}:${principal.id}`;
}
export const MAX_WALLET_AMOUNT_LENGTH = 8_192;
export const MAX_WALLET_AUTH_SCOPES = 64;
function validCounter(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function clonePrincipal(principal: WalletAuthorizedResource['principal']): WalletAuthorizedResource['principal'] {
return { ...principal };
}
function cloneResource(resource: WalletAuthorizedResource): WalletAuthorizedResource {
return {
...resource,
principal: clonePrincipal(resource.principal),
resource: { ...resource.resource, principal: { ...resource.resource.principal }, requiredScopes: [...resource.resource.requiredScopes] },
grantedScopes: [...resource.grantedScopes],
};
}
function cloneSnapshot(snapshot: WalletBalanceSnapshot): WalletBalanceSnapshot {
return { ...snapshot, principal: clonePrincipal(snapshot.principal), coverage: { ...snapshot.coverage } };
}
function cloneFence(fence: WalletAuthorityFence): WalletAuthorityFence {
return { ...fence, principal: clonePrincipal(fence.principal), scopes: [...fence.scopes] };
}
function cloneFailure(failure: WalletRefreshFailure): WalletRefreshFailure {
return { ...failure };
}
function canonicalScopes(value: unknown, allowEmpty = false): string[] | null {
if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > MAX_WALLET_AUTH_SCOPES) return null;
if (value.some((scope) => typeof scope !== 'string' || scope.length === 0 || scope.length > 256)) return null;
const scopes = [...value] as string[];
if (new Set(scopes).size !== scopes.length) return null;
return scopes.sort();
}
export function validateWalletScopes(value: unknown, allowEmpty = false): readonly string[] {
const scopes = canonicalScopes(value, allowEmpty);
if (!scopes) throw new TypeError('wallet scopes are invalid');
return scopes;
}
export function validateWalletAuthorityFence(fence: WalletAuthorityFence): void {
if (!fence || typeof fence.userId !== 'string' || !fence.userId || !Number.isSafeInteger(fence.characterId) || fence.characterId <= 0 || !fence.principal || fence.principal.kind !== 'character' || !isDecimalId(fence.principal.id) || fence.principal.id !== String(fence.characterId) || fence.principal.ownerUserId !== fence.userId || !validCounter(fence.grantRevision) || !validCounter(fence.authorizationEpoch) || !validCounter(fence.credentialVersion) || !canonicalScopes(fence.scopes)?.includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet authority fence is invalid');
}
export function validateWalletAmount(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_WALLET_AMOUNT_LENGTH) throw new RangeError('wallet amount exceeds resource bounds');
const parsed = parseIsk(value);
const canonical = parsed.toString(10);
if (value !== canonical) throw new TypeError('wallet amount is not canonical signed integer cents');
return canonical;
}
function assertResource(resource: WalletAuthorizedResource): void {
if (!resource || typeof resource.userId !== 'string' || !resource.userId || !Number.isSafeInteger(resource.characterId) || resource.characterId <= 0) throw new TypeError('wallet resource identity is invalid');
if (!isDecimalId(resource.principal.id) || resource.principal.ownerUserId !== resource.userId || resource.principal.kind !== 'character' || resource.principal.id !== String(resource.characterId)) throw new TypeError('wallet resource principal is invalid');
if (!resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.id !== resource.principal.id || resource.resource.principal.ownerUserId !== resource.userId) throw new TypeError('wallet resource selector is invalid');
if (!Array.isArray(resource.resource.requiredScopes) || !canonicalScopes(resource.resource.requiredScopes)?.includes(ESI_WALLET_SCOPE) || resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('wallet resource scope pin is invalid');
if (!canonicalScopes(resource.grantedScopes, true)) throw new TypeError('wallet granted scopes are invalid');
if (!validCounter(resource.grantRevision) || !validCounter(resource.authorizationEpoch) || (resource.credentialVersion !== null && !validCounter(resource.credentialVersion))) throw new TypeError('wallet authority counters are invalid');
if (!['granted', 'missing'].includes(resource.consent)) throw new TypeError('wallet consent state is invalid');
if (!['active', 'revocation_pending', 'quarantined', 'revoked', 'transferred'].includes(resource.authorizationStatus)) throw new TypeError('wallet authorization state is invalid');
if (resource.consent === 'granted' && !resource.grantedScopes.includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet granted consent lacks the required scope');
if (resource.consent === 'granted' && resource.credentialVersion === null) throw new TypeError('wallet granted consent lacks credential version');
}
export function walletFenceForResource(resource: WalletAuthorizedResource): WalletAuthorityFence | null {
assertResource(resource);
if (resource.consent !== 'granted' || resource.authorizationStatus !== 'active' || resource.credentialVersion === null) return null;
return {
userId: resource.userId,
characterId: resource.characterId,
principal: clonePrincipal(resource.principal),
grantRevision: resource.grantRevision,
authorizationEpoch: resource.authorizationEpoch,
credentialVersion: resource.credentialVersion,
scopes: [...resource.grantedScopes].sort(),
};
}
export function sameWalletFence(left: WalletAuthorityFence, right: WalletAuthorityFence): boolean {
return left.userId === right.userId
&& left.characterId === right.characterId
&& left.principal.kind === 'character'
&& right.principal.kind === 'character'
&& left.principal.id === right.principal.id
&& left.principal.ownerUserId === right.principal.ownerUserId
&& left.grantRevision === right.grantRevision
&& left.authorizationEpoch === right.authorizationEpoch
&& left.credentialVersion === right.credentialVersion
&& [...left.scopes].sort().join('\u0000') === [...right.scopes].sort().join('\u0000');
}
export function validateWalletSnapshot(snapshot: WalletBalanceSnapshot): void {
if (!snapshot || typeof snapshot.userId !== 'string' || !snapshot.userId || !Number.isSafeInteger(snapshot.characterId) || snapshot.characterId <= 0) throw new TypeError('wallet snapshot identity is invalid');
if (snapshot.principal.kind !== 'character' || snapshot.principal.ownerUserId !== snapshot.userId || snapshot.principal.id !== String(snapshot.characterId)) throw new TypeError('wallet snapshot principal is invalid');
validateWalletAmount(snapshot.amount);
if (!Number.isFinite(snapshot.fetchedAt) || snapshot.fetchedAt < 0 || !Number.isFinite(snapshot.expiresAt) || snapshot.expiresAt <= snapshot.fetchedAt) throw new TypeError('wallet snapshot timestamps are invalid');
if (snapshot.upstreamEffectiveAt !== null && (!Number.isFinite(snapshot.upstreamEffectiveAt) || snapshot.upstreamEffectiveAt < 0)) throw new TypeError('wallet upstream timestamp is invalid');
if (snapshot.coverage.kind !== 'snapshot' || snapshot.coverage.complete !== true || typeof snapshot.coverage.generation !== 'string' || !snapshot.coverage.generation) throw new TypeError('wallet snapshot coverage is invalid');
if (snapshot.generation !== snapshot.coverage.generation || !snapshot.generation || snapshot.generation.length > MAX_WALLET_AMOUNT_LENGTH) throw new TypeError('wallet snapshot generation is invalid');
if (!validCounter(snapshot.authorizationRevision) || !validCounter(snapshot.authorizationEpoch) || !validCounter(snapshot.credentialVersion)) throw new TypeError('wallet snapshot authority counters are invalid');
}
export function validateWalletRefreshFailure(failure: WalletRefreshFailure): void {
if (!failure || typeof failure.userId !== 'string' || !failure.userId || !Number.isSafeInteger(failure.characterId) || failure.characterId <= 0 || !Number.isFinite(failure.failedAt) || failure.failedAt < 0 || typeof failure.category !== 'string' || !failure.category || typeof failure.retryable !== 'boolean' || !validCounter(failure.authorizationRevision) || !validCounter(failure.authorizationEpoch) || !validCounter(failure.credentialVersion) || typeof failure.generation !== 'string' || !failure.generation || failure.generation.length > MAX_WALLET_AMOUNT_LENGTH || (failure.retryAt !== undefined && (!Number.isFinite(failure.retryAt) || failure.retryAt < failure.failedAt)) || (failure.cooldownGroup !== undefined && (typeof failure.cooldownGroup !== 'string' || !failure.cooldownGroup))) throw new TypeError('wallet refresh failure is invalid');
}
const validateFailure = validateWalletRefreshFailure;
function sameSnapshot(left: WalletBalanceSnapshot, right: WalletBalanceSnapshot): boolean {
return left.userId === right.userId && left.characterId === right.characterId && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.amount === right.amount && left.fetchedAt === right.fetchedAt && left.expiresAt === right.expiresAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt && left.generation === right.generation && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion;
}
function sameFailure(left: WalletRefreshFailure, right: WalletRefreshFailure): boolean {
return left.userId === right.userId && left.characterId === right.characterId && left.failedAt === right.failedAt && left.category === right.category && left.retryable === right.retryable && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion && left.generation === right.generation && left.retryAt === right.retryAt && left.cooldownGroup === right.cooldownGroup && left.message === right.message && left.jobId === right.jobId;
}
/**
* Fixture-only memory adapter. It intentionally contains no Convex/schema or
* production queue behavior; a durable adapter must preserve these fences and
* checkpoint the stage/publication transition atomically.
*/
export class InMemoryWalletBalanceRepository implements WalletBalanceRepository {
private readonly resources = new Map<string, WalletAuthorizedResource>();
private readonly snapshots = new Map<string, WalletBalanceSnapshot>();
private readonly failures = new Map<string, WalletRefreshFailure>();
private readonly credentials = new Map<string, WalletCredential>();
private readonly publishedJobs = new Map<string, { fence: WalletAuthorityFence; outcome: 'success'; snapshot: WalletBalanceSnapshot } | { fence: WalletAuthorityFence; outcome: 'failure'; failure: WalletRefreshFailure }>();
public constructor(initial: {
resources?: readonly WalletAuthorizedResource[];
snapshots?: readonly WalletBalanceSnapshot[];
failures?: readonly WalletRefreshFailure[];
credentials?: readonly { fence: WalletAuthorityFence; credential: WalletCredential }[];
} = {}) {
for (const resource of initial.resources ?? []) this.seedResource(resource);
for (const snapshot of initial.snapshots ?? []) this.seedSnapshot(snapshot);
for (const failure of initial.failures ?? []) this.seedFailure(failure);
for (const entry of initial.credentials ?? []) this.seedCredential(entry.fence, entry.credential);
}
public seedResource(resource: WalletAuthorizedResource): void {
assertResource(resource);
const key = walletResourceKey(resource.userId, resource.characterId);
if (this.resources.has(key)) throw new TypeError('duplicate wallet resource');
this.resources.set(key, cloneResource(resource));
}
/** Fixture-only authority rotation hook for stale-worker tests. */
public replaceResource(resource: WalletAuthorizedResource): void {
assertResource(resource);
this.resources.set(walletResourceKey(resource.userId, resource.characterId), cloneResource(resource));
}
public seedSnapshot(snapshot: WalletBalanceSnapshot): void {
validateWalletSnapshot(snapshot);
this.snapshots.set(walletResourceKey(snapshot.userId, snapshot.characterId), cloneSnapshot(snapshot));
}
public seedFailure(failure: WalletRefreshFailure): void {
validateFailure(failure);
this.failures.set(walletResourceKey(failure.userId, failure.characterId), cloneFailure(failure));
}
public seedCredential(fence: WalletAuthorityFence, credential: WalletCredential): void {
validateWalletAuthorityFence(fence);
if (typeof credential.accessToken !== 'string' || !credential.accessToken) throw new TypeError('wallet credential is invalid');
const key = credential.authorizationKey;
if (!key || key.userId !== fence.userId || key.principal !== walletPrincipalKey(fence.principal) || key.characterId !== fence.characterId || key.grantRevision !== fence.grantRevision || key.authorizationEpoch !== fence.authorizationEpoch || key.credentialVersion !== fence.credentialVersion || canonicalScopes(key.scopes)?.join('\u0000') !== canonicalScopes(fence.scopes)?.join('\u0000')) throw new TypeError('wallet credential authority does not match');
this.credentials.set(walletResourceKey(fence.userId, fence.characterId), { accessToken: credential.accessToken, authorizationKey: { ...key, scopes: [...key.scopes] } });
}
public async authorizedResources(userId: string): Promise<readonly WalletAuthorizedResource[]> {
if (typeof userId !== 'string' || !userId) throw new TypeError('wallet user is invalid');
return [...this.resources.values()].filter((resource) => resource.userId === userId).sort((a, b) => a.characterId - b.characterId).map(cloneResource);
}
public async snapshot(userId: string, characterId: number): Promise<WalletBalanceSnapshot | null> {
if (typeof userId !== 'string' || !userId || !Number.isSafeInteger(characterId) || characterId <= 0) throw new TypeError('wallet snapshot lookup is invalid');
const snapshot = this.snapshots.get(walletResourceKey(userId, characterId));
return snapshot ? cloneSnapshot(snapshot) : null;
}
public async failure(userId: string, characterId: number): Promise<WalletRefreshFailure | null> {
const failure = this.failures.get(walletResourceKey(userId, characterId));
if (!failure || failure.userId !== userId || failure.characterId !== characterId) return null;
const current = await this.currentAuthority(userId, characterId);
if (!current || failure.authorizationRevision !== current.grantRevision || failure.authorizationEpoch !== current.authorizationEpoch || failure.credentialVersion !== current.credentialVersion) return null;
return cloneFailure(failure);
}
public async currentAuthority(userId: string, characterId: number): Promise<WalletAuthorityFence | null> {
const resource = this.resources.get(walletResourceKey(userId, characterId));
return resource ? walletFenceForResource(resource) : null;
}
public async credential(fence: WalletAuthorityFence): Promise<WalletCredential | null> {
validateWalletAuthorityFence(fence);
const current = await this.currentAuthority(fence.userId, fence.characterId);
if (!current || !sameWalletFence(current, fence)) return null;
const credential = this.credentials.get(walletResourceKey(fence.userId, fence.characterId));
if (!credential) return null;
const key = credential.authorizationKey;
if (typeof credential.accessToken !== 'string' || !credential.accessToken || !key || key.userId !== fence.userId || key.principal !== walletPrincipalKey(fence.principal) || key.characterId !== fence.characterId || key.grantRevision !== fence.grantRevision || key.authorizationEpoch !== fence.authorizationEpoch || key.credentialVersion !== fence.credentialVersion || canonicalScopes(key.scopes)?.join('\u0000') !== canonicalScopes(fence.scopes)?.join('\u0000')) return null;
return { accessToken: credential.accessToken, authorizationKey: { ...key, scopes: [...key.scopes] } };
}
public async publishSnapshot(publication: WalletSnapshotPublication): Promise<WalletPublicationResult> {
validateWalletAuthorityFence(publication.fence);
validateWalletSnapshot(publication.snapshot);
if (!Number.isFinite(publication.publishedAt) || publication.publishedAt < 0 || publication.snapshot.fetchedAt > publication.publishedAt) return { accepted: false, reason: 'invalid_snapshot' };
if (publication.snapshot.userId !== publication.fence.userId || publication.snapshot.characterId !== publication.fence.characterId || publication.snapshot.principal.id !== publication.fence.principal.id || publication.snapshot.principal.ownerUserId !== publication.fence.principal.ownerUserId || publication.snapshot.authorizationRevision !== publication.fence.grantRevision || publication.snapshot.authorizationEpoch !== publication.fence.authorizationEpoch || publication.snapshot.credentialVersion !== publication.fence.credentialVersion) return { accepted: false, reason: 'mismatched_character' };
const current = await this.currentAuthority(publication.fence.userId, publication.fence.characterId);
if (!current) return { accepted: false, reason: 'authorization_revoked' };
if (!sameWalletFence(current, publication.fence)) {
if (current.grantRevision !== publication.fence.grantRevision) return { accepted: false, reason: 'stale_grant' };
if (current.authorizationEpoch !== publication.fence.authorizationEpoch) return { accepted: false, reason: 'stale_authorization_epoch' };
if (current.credentialVersion !== publication.fence.credentialVersion) return { accepted: false, reason: 'stale_credential' };
return { accepted: false, reason: 'borrowed_resource' };
}
const previousJob = this.publishedJobs.get(publication.jobId);
if (previousJob) {
if (previousJob.outcome === 'success' && sameWalletFence(previousJob.fence, publication.fence) && sameSnapshot(previousJob.snapshot, publication.snapshot)) return { accepted: true, snapshot: cloneSnapshot(previousJob.snapshot), outcome: 'success' };
return { accepted: false, reason: 'duplicate_publication' };
}
const key = walletResourceKey(publication.fence.userId, publication.fence.characterId);
const prior = this.snapshots.get(key);
const priorFailure = this.failures.get(key);
if (priorFailure && priorFailure.authorizationRevision === publication.fence.grantRevision && priorFailure.authorizationEpoch === publication.fence.authorizationEpoch && priorFailure.credentialVersion === publication.fence.credentialVersion && priorFailure.failedAt >= publication.snapshot.fetchedAt) return { accepted: false, reason: 'stale_generation' };
if (prior) {
if (prior.generation === publication.snapshot.generation) {
if (!sameSnapshot(prior, publication.snapshot)) return { accepted: false, reason: 'stale_generation' };
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'success', snapshot: cloneSnapshot(prior) });
return { accepted: true, snapshot: cloneSnapshot(prior), outcome: 'success' };
}
if (prior.fetchedAt >= publication.snapshot.fetchedAt) return { accepted: false, reason: 'stale_generation' };
}
this.snapshots.set(key, cloneSnapshot(publication.snapshot));
if (!priorFailure || priorFailure.failedAt < publication.snapshot.fetchedAt) this.failures.delete(key);
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'success', snapshot: cloneSnapshot(publication.snapshot) });
return { accepted: true, snapshot: cloneSnapshot(publication.snapshot), outcome: 'success' };
}
public async publishFailure(publication: WalletFailurePublication): Promise<WalletPublicationResult> {
validateWalletAuthorityFence(publication.fence);
validateFailure(publication.failure);
if (publication.failure.userId !== publication.fence.userId || publication.failure.characterId !== publication.fence.characterId || publication.failure.authorizationRevision !== publication.fence.grantRevision || publication.failure.authorizationEpoch !== publication.fence.authorizationEpoch || publication.failure.credentialVersion !== publication.fence.credentialVersion || !Number.isFinite(publication.publishedAt) || publication.failure.failedAt > publication.publishedAt) return { accepted: false, reason: 'mismatched_character' };
const current = await this.currentAuthority(publication.fence.userId, publication.fence.characterId);
if (!current) return { accepted: false, reason: 'authorization_revoked' };
if (!sameWalletFence(current, publication.fence)) return { accepted: false, reason: current.grantRevision !== publication.fence.grantRevision ? 'stale_grant' : current.authorizationEpoch !== publication.fence.authorizationEpoch ? 'stale_authorization_epoch' : current.credentialVersion !== publication.fence.credentialVersion ? 'stale_credential' : 'borrowed_resource' };
const previousJob = this.publishedJobs.get(publication.jobId);
if (previousJob) {
if (previousJob.outcome === 'failure' && sameWalletFence(previousJob.fence, publication.fence) && sameFailure(previousJob.failure, publication.failure)) return { accepted: true, snapshot: null, outcome: 'failure' };
return { accepted: false, reason: 'duplicate_publication' };
}
const key = walletResourceKey(publication.failure.userId, publication.failure.characterId);
const prior = this.failures.get(key);
const priorSnapshot = this.snapshots.get(key);
if (priorSnapshot && priorSnapshot.authorizationRevision === publication.fence.grantRevision && priorSnapshot.authorizationEpoch === publication.fence.authorizationEpoch && priorSnapshot.credentialVersion === publication.fence.credentialVersion && priorSnapshot.fetchedAt >= publication.failure.failedAt) return { accepted: false, reason: 'stale_generation' };
if (prior) {
if (prior.generation === publication.failure.generation) {
if (!sameFailure(prior, publication.failure)) return { accepted: false, reason: 'stale_generation' };
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'failure', failure: cloneFailure(prior) });
return { accepted: true, snapshot: null, outcome: 'failure' };
}
if (prior.failedAt >= publication.failure.failedAt) return { accepted: false, reason: 'stale_generation' };
}
this.failures.set(key, cloneFailure(publication.failure));
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'failure', failure: cloneFailure(publication.failure) });
return { accepted: true, snapshot: null, outcome: 'failure' };
}
}
/** Build the shared component shape without inventing an upstream effective time. */
export function walletComponent(snapshot: WalletBalanceSnapshot): WalletBalanceComponent {
validateWalletSnapshot(snapshot);
return {
component: 'wallet_balance',
principal: clonePrincipal(snapshot.principal),
amount: snapshot.amount,
fetchedAt: snapshot.fetchedAt,
upstreamEffectiveAt: snapshot.upstreamEffectiveAt,
coverage: { ...snapshot.coverage },
authorizationRevision: snapshot.authorizationRevision,
credentialVersion: snapshot.credentialVersion,
};
}
export type WalletBalanceSharedComponent = WalletBalanceComponent;
export type WalletBalanceSharedProvenance = CompleteReportProvenance;
+339
View File
@@ -0,0 +1,339 @@
import type {
CompleteReportProvenance,
DatasetKind,
ReportDatasetPin,
ReportProvenance,
ResourcePrincipal,
SelectedResource,
WalletBalanceComponent,
} from '../../domain/model';
import { compareDecimalIds, isDecimalId } from '../../domain/inventory-location';
import { sumIsk } from '../../domain/money';
import { ESI_WALLET_SCOPE } from '../esi';
import {
validateWalletRefreshFailure,
validateWalletScopes,
validateWalletSnapshot,
walletComponent,
walletFenceForResource,
type WalletAuthorizedResource,
type WalletBalanceRepository,
type WalletBalanceSnapshot,
type WalletRefreshFailure,
} from './balance-repository';
import type { WalletBalanceJobQueue } from './balance-job';
export const DEFAULT_WALLET_MAX_CHARACTERS = 100;
export const DEFAULT_WALLET_ENQUEUE_LIMIT = 100;
export const WALLET_SELECTOR_MAX_IDS = 100;
export type WalletBalanceRequest = {
userId: string;
/** Character IDs are selectors only; authorization is resolved by the repository. */
characterIds?: readonly (string | number)[];
};
export type WalletFreshness = 'fresh' | 'stale' | 'missing' | 'failed' | 'consent_required' | 'unavailable';
export type WalletBalanceCharacterView = {
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
characterId: number;
characterName: string | null;
consent: 'granted' | 'missing';
freshness: WalletFreshness;
amount: string | null;
fetchedAt: number | null;
expiresAt: number | null;
upstreamEffectiveAt: number | null;
authorizationRevision: number;
authorizationEpoch: number;
credentialVersion: number | null;
failure: WalletRefreshFailure | null;
};
type WalletBalanceCommonView = {
characters: readonly WalletBalanceCharacterView[];
components: readonly WalletBalanceComponent[];
/** Explicitly wallet-only; this is never an asset/wealth total. */
walletOnlyTotal: string | null;
missing: readonly Extract<ResourcePrincipal, { kind: 'character' }>[];
/** Queue outcomes are diagnostic only and never turn stale data into ready data. */
refresh: { attempted: number; queued: number; duplicate: number; cooldown: number; skipped: number; failed: number };
};
export type WalletBalanceReadyView = WalletBalanceCommonView & {
status: 'ready';
walletTotal: string;
walletOnlyTotal: string;
provenance: CompleteReportProvenance;
};
export type WalletBalancePartialView = WalletBalanceCommonView & {
status: 'partial';
walletTotal: null;
walletOnlyTotal: null;
provenance: ReportProvenance;
};
export type WalletBalanceBlockedView = WalletBalanceCommonView & {
status: 'blocked' | 'unavailable';
walletTotal: null;
walletOnlyTotal: null;
reason: string;
provenance?: never;
};
/** Shared result consumed by both a future UI route and assistant adapter. */
export type WalletBalanceView = WalletBalanceReadyView | WalletBalancePartialView | WalletBalanceBlockedView;
export type WalletBalanceServiceOptions = {
repository: WalletBalanceRepository;
queue?: WalletBalanceJobQueue;
now?: () => number;
maxCharacters?: number;
enqueueLimit?: number;
};
function finiteNow(now: () => number): number {
const value = now();
if (!Number.isFinite(value) || value < 0) throw new RangeError('wallet service clock returned an invalid timestamp');
return value;
}
function clonePrincipal(principal: Extract<ResourcePrincipal, { kind: 'character' }>): Extract<ResourcePrincipal, { kind: 'character' }> {
return { ...principal };
}
function validateResource(resource: WalletAuthorizedResource, userId: string): void {
if (!resource || resource.userId !== userId || resource.principal.kind !== 'character' || resource.principal.ownerUserId !== userId || resource.principal.id !== String(resource.characterId) || !Number.isSafeInteger(resource.characterId) || resource.characterId <= 0 || !resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.id !== resource.principal.id || resource.resource.principal.ownerUserId !== userId || !validateWalletScopes(resource.resource.requiredScopes).includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet repository returned malformed or borrowed resource');
validateWalletScopes(resource.grantedScopes, true);
if (!Number.isSafeInteger(resource.grantRevision) || resource.grantRevision < 0 || !Number.isSafeInteger(resource.authorizationEpoch) || resource.authorizationEpoch < 0 || (resource.credentialVersion !== null && (!Number.isSafeInteger(resource.credentialVersion) || resource.credentialVersion < 0))) throw new TypeError('wallet repository returned malformed authority counters');
if (!['granted', 'missing'].includes(resource.consent) || !['active', 'revocation_pending', 'quarantined', 'revoked', 'transferred'].includes(resource.authorizationStatus)) throw new TypeError('wallet repository returned malformed consent state');
if (resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('wallet resource pin does not match authority counters');
if (resource.consent === 'granted' && (!resource.grantedScopes.includes(ESI_WALLET_SCOPE) || resource.credentialVersion === null)) throw new TypeError('wallet granted resource lacks exact authority');
}
function normalizeSelectedIds(value: readonly (string | number)[] | undefined): string[] | null {
if (value === undefined) return null;
if (!Array.isArray(value) || value.length === 0 || value.length > WALLET_SELECTOR_MAX_IDS) throw new RangeError('wallet character selection cap exceeded');
const ids = value.map((entry, index) => {
if (typeof entry === 'number') {
if (!Number.isSafeInteger(entry) || entry <= 0) throw new TypeError(`wallet characterIds[${index}] is invalid`);
return String(entry);
}
if (!isDecimalId(entry)) throw new TypeError(`wallet characterIds[${index}] is invalid`);
return entry;
});
if (new Set(ids).size !== ids.length) throw new TypeError('wallet character selection contains duplicates');
return ids.sort(compareDecimalIds);
}
function sameResource(left: SelectedResource, right: SelectedResource): boolean {
return left.principal.kind === 'character' && right.principal.kind === 'character' && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && [...left.requiredScopes].sort().join('\u0000') === [...right.requiredScopes].sort().join('\u0000');
}
function incompleteCoverage(): Extract<WalletBalanceSnapshot['coverage'], { kind: 'snapshot' }> {
return { kind: 'snapshot', generation: null, complete: false };
}
function resourceMaps(resources: readonly WalletAuthorizedResource[]) {
const authorizationRevisions: Record<string, number> = {};
const authorizationEpochs: Record<string, number> = {};
const snapshotGenerations: Record<string, string> = {};
for (const resource of resources) {
authorizationRevisions[`character:${resource.characterId}`] = resource.grantRevision;
authorizationEpochs[`character:${resource.characterId}`] = resource.authorizationEpoch;
}
return { authorizationRevisions, authorizationEpochs, snapshotGenerations };
}
function provenance(resources: readonly WalletAuthorizedResource[], snapshots: ReadonlyMap<number, WalletBalanceSnapshot>, now: number, complete: boolean): ReportProvenance | CompleteReportProvenance {
const selectedResources = resources.map((resource) => ({ ...resource.resource, principal: clonePrincipal(resource.principal), requiredScopes: [...resource.resource.requiredScopes] }));
const maps = resourceMaps(resources);
const datasetPins: ReportDatasetPin[] = resources.map((resource) => {
const snapshot = snapshots.get(resource.characterId);
if (snapshot) maps.snapshotGenerations[`character:${resource.characterId}`] = snapshot.generation;
return {
selectedResource: resource.resource,
principal: resource.principal,
dataset: 'wallet_balance',
coverage: snapshot?.coverage ?? incompleteCoverage(),
authorizationRevision: resource.grantRevision,
authorizationEpoch: resource.authorizationEpoch,
fetchedAt: snapshot?.fetchedAt ?? null,
upstreamEffectiveAt: snapshot?.upstreamEffectiveAt ?? null,
};
});
const base: ReportProvenance = {
reportId: `wallet-${now}-${selectedResources.map((resource) => resource.principal.id).join(',')}`,
generatedAt: now,
fetchedAt: Object.fromEntries(resources.map((resource) => [`character:${resource.characterId}`, snapshots.get(resource.characterId)?.fetchedAt ?? null])),
upstreamEffectiveAt: Object.fromEntries(resources.map((resource) => [`character:${resource.characterId}`, snapshots.get(resource.characterId)?.upstreamEffectiveAt ?? null])),
input: {
selectedResources,
requiredDatasetKinds: ['wallet_balance'] as readonly [DatasetKind],
datasetPins,
authorizationRevisions: maps.authorizationRevisions,
authorizationEpochs: maps.authorizationEpochs,
snapshotGenerations: maps.snapshotGenerations,
appendCheckpoints: {},
referenceVersion: null,
priceVersion: null,
...(complete ? { pinSet: 'exact_selected_resource_dataset_set' as const } : {}),
} as ReportProvenance['input'],
sourceSkew: false,
assumptions: ['Wallet balance is a separately labelled cash component; assets, orders, journals and wealth are not included.', 'ESI supplied no upstream effective timestamp; upstreamEffectiveAt is null.'],
};
return complete ? base as CompleteReportProvenance : base;
}
function characterView(resource: WalletAuthorizedResource, snapshot: WalletBalanceSnapshot | null, failure: WalletRefreshFailure | null, now: number): WalletBalanceCharacterView {
const allowed = resource.consent === 'granted' && resource.authorizationStatus === 'active' && resource.credentialVersion !== null;
if (!allowed) return {
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'missing', freshness: 'consent_required', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null,
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: null,
};
if (!snapshot) return {
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'granted', freshness: failure ? 'failed' : 'missing', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null,
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure,
};
const failed = failure !== null && failure.failedAt >= snapshot.fetchedAt;
return {
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'granted', freshness: failed ? 'failed' : now < snapshot.expiresAt ? 'fresh' : 'stale', amount: snapshot.amount, fetchedAt: snapshot.fetchedAt, expiresAt: snapshot.expiresAt, upstreamEffectiveAt: snapshot.upstreamEffectiveAt,
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: failed ? failure : null,
};
}
/**
* Deterministic wallet read core shared by an eventual UI route and assistant
* adapter. Opening the view may enqueue stale observations, but never waits for
* a refresh or replaces a valid cache with an unnecessary job.
*/
export class WalletBalanceService {
private readonly now: () => number;
private readonly maxCharacters: number;
private readonly enqueueLimit: number;
public constructor(private readonly options: WalletBalanceServiceOptions) {
this.now = options.now ?? (() => Date.now());
this.maxCharacters = options.maxCharacters ?? DEFAULT_WALLET_MAX_CHARACTERS;
this.enqueueLimit = options.enqueueLimit ?? DEFAULT_WALLET_ENQUEUE_LIMIT;
if (!Number.isSafeInteger(this.maxCharacters) || this.maxCharacters < 1 || !Number.isSafeInteger(this.enqueueLimit) || this.enqueueLimit < 1) throw new RangeError('wallet service bounds are invalid');
}
public async read(request: WalletBalanceRequest): Promise<WalletBalanceView> {
if (!request || typeof request.userId !== 'string' || !request.userId) throw new TypeError('wallet user is required');
const now = finiteNow(this.now);
const selectedIds = normalizeSelectedIds(request.characterIds);
const all = await this.options.repository.authorizedResources(request.userId);
if (!Array.isArray(all) || all.length > this.maxCharacters) throw new RangeError('wallet authorized resource cap exceeded');
const seen = new Set<string>();
for (const resource of all) {
validateResource(resource, request.userId);
if (seen.has(resource.principal.id)) throw new TypeError('wallet repository returned duplicate characters');
seen.add(resource.principal.id);
}
let resources: WalletAuthorizedResource[];
if (selectedIds === null) resources = [...all];
else {
resources = [];
for (const id of selectedIds) {
const resource = all.find((candidate) => candidate.principal.id === id);
if (!resource) return { status: 'blocked', characters: [], components: [], walletTotal: null, walletOnlyTotal: null, missing: [], reason: 'Selected character is not an authorized linked resource', refresh: { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 } };
resources.push(resource);
}
}
resources.sort((a, b) => compareDecimalIds(a.principal.id, b.principal.id));
if (resources.length === 0) return { status: 'blocked', characters: [], components: [], walletTotal: null, walletOnlyTotal: null, missing: [], reason: 'No authorized linked wallet resources are available', refresh: { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 } };
const snapshots = new Map<number, WalletBalanceSnapshot>();
const failures = new Map<number, WalletRefreshFailure>();
const views: WalletBalanceCharacterView[] = [];
const refresh = { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 };
for (const resource of resources) {
let snapshot: WalletBalanceSnapshot | null = null;
let failure: WalletRefreshFailure | null = null;
try {
snapshot = await this.options.repository.snapshot(request.userId, resource.characterId);
failure = await this.options.repository.failure(request.userId, resource.characterId);
} catch {
views.push({ principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: resource.consent, freshness: 'unavailable', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null, authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: null });
refresh.skipped += 1;
continue;
}
if (snapshot) {
validateWalletSnapshot(snapshot);
if (snapshot.fetchedAt > now || (snapshot.upstreamEffectiveAt !== null && snapshot.upstreamEffectiveAt > snapshot.fetchedAt)) throw new TypeError('wallet repository returned future or inconsistent snapshot provenance');
if (snapshot.userId !== request.userId || snapshot.characterId !== resource.characterId || !sameResource({ ...resource.resource, principal: resource.principal }, snapshotToResource(snapshot, resource))) throw new TypeError('wallet repository snapshot authority does not match selected resource');
if (snapshot.authorizationRevision !== resource.grantRevision || snapshot.authorizationEpoch !== resource.authorizationEpoch || snapshot.credentialVersion !== resource.credentialVersion) {
// A snapshot from an earlier authority epoch remains stored for audit,
// but it is not readable data for this request.
snapshot = null;
} else snapshots.set(resource.characterId, snapshot);
}
if (failure) {
validateWalletRefreshFailure(failure);
if (failure.failedAt > now) throw new TypeError('wallet repository returned future failure provenance');
if (failure.userId !== request.userId || failure.characterId !== resource.characterId || failure.authorizationRevision !== resource.grantRevision || failure.authorizationEpoch !== resource.authorizationEpoch || failure.credentialVersion !== resource.credentialVersion) failure = null;
}
if (failure) failures.set(resource.characterId, failure);
const view = characterView(resource, snapshot, failure, now);
views.push(view);
const stale = view.consent === 'granted' && (view.freshness === 'stale' || view.freshness === 'missing' || (view.freshness === 'failed' && failure?.retryable !== false));
if (stale && this.options.queue && refresh.attempted < this.enqueueLimit) {
refresh.attempted += 1;
const fence = walletFenceForResource(resource);
if (!fence) { refresh.skipped += 1; continue; }
try {
const result = await this.options.queue.enqueue({ userId: request.userId, characterId: resource.characterId, fence, now });
if (result.status === 'queued') refresh.queued += 1;
else if (result.status === 'duplicate') refresh.duplicate += 1;
else if (result.status === 'cooldown') refresh.cooldown += 1;
else refresh.failed += 1;
} catch { refresh.failed += 1; }
} else if (stale) refresh.skipped += 1;
}
const components = resources.map((resource) => snapshots.get(resource.characterId)).filter((snapshot): snapshot is WalletBalanceSnapshot => snapshot !== undefined && resourceIsGranted(resources, snapshot.characterId)).map(walletComponent);
const missing = views.filter((view) => view.freshness !== 'fresh').map((view) => clonePrincipal(view.principal));
const complete = views.length === resources.length && views.every((view) => view.freshness === 'fresh' && view.consent === 'granted') && components.length === resources.length;
const walletTotal = complete ? sumIsk(components.map((component) => component.amount)) : null;
const report = provenance(resources, snapshots, now, complete);
if (complete) return {
status: 'ready',
characters: views,
components,
walletTotal: walletTotal!,
walletOnlyTotal: walletTotal!,
missing,
provenance: report as CompleteReportProvenance,
refresh,
};
return {
status: 'partial',
characters: views,
components,
walletTotal: null,
walletOnlyTotal: null,
missing,
provenance: report as ReportProvenance,
refresh,
};
}
public execute(request: WalletBalanceRequest): Promise<WalletBalanceView> { return this.read(request); }
}
function resourceIsGranted(resources: readonly WalletAuthorizedResource[], characterId: number): boolean {
const resource = resources.find((candidate) => candidate.characterId === characterId);
return !!resource && resource.consent === 'granted' && resource.authorizationStatus === 'active';
}
function snapshotToResource(snapshot: WalletBalanceSnapshot, resource: WalletAuthorizedResource): SelectedResource {
return { ...resource.resource, principal: { ...snapshot.principal }, requiredScopes: [...resource.resource.requiredScopes] };
}
export async function readWalletBalance(options: WalletBalanceServiceOptions, request: WalletBalanceRequest): Promise<WalletBalanceView> {
return new WalletBalanceService(options).read(request);
}
export const queryWalletBalance = readWalletBalance;
export type WalletBalanceResult = WalletBalanceView;
export type WalletViewResult = WalletBalanceView;
+16 -1
View File
@@ -1,7 +1,22 @@
import type { LayoutServerLoad } from './$types';
import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application';
import { capabilityEnabled, type ReleaseCapability } from '$lib/server/release-gate';
export const load: LayoutServerLoad = async (event) => {
const config = configuration(); const current = await session(event);
const config = configuration();
const path = event.url.pathname;
const identityPage = path === '/characters' || path.startsWith('/characters/');
const assetPage = path === '/assets' || path.startsWith('/assets/');
const sessionNeeded = identityPage
? capabilityEnabled('identity', config.capabilityAllowlist)
: assetPage
? capabilityEnabled('asset_search', config.capabilityAllowlist) || capabilityEnabled('asset_valuation', config.capabilityAllowlist)
: (['assistant_dispatch', 'asset_search', 'asset_valuation', 'identity', 'session_token', 'subscriptions'] as readonly ReleaseCapability[])
.some((capability) => capabilityEnabled(capability, config.capabilityAllowlist));
// The root shell owns the assistant/nav session indicator. Skip its backend
// lookup only when every capability represented by that shell is disabled;
// separately enabled capabilities retain the existing authenticated state.
const current = sessionNeeded ? await session(event) : null;
return { mode: config.mode, userId: current?.userId ?? null, convexUrl: config.publicConvexUrl, jevEnabled: !!config.jevKey };
};
+50 -11
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import AssetResult from '$lib/components/AssetResult.svelte';
import { cancelSyncJob, enqueueSync, syncJobStatus, syncTerminal, type SyncJob, type SyncSkip } from '$lib/client/sync-queue';
import type { AssetOperation } from '$lib/domain/model';
let { data } = $props();
let prompt = $state('');
@@ -10,29 +11,66 @@
let message = $state('');
let failure = $state('');
let operation = $state<AssetOperation | null>(null);
let syncJobs = $state<SyncJob[]>([]);
let syncSkipped = $state<SyncSkip[]>([]);
let pollController: AbortController | null = null;
let enqueueController: AbortController | null = null;
let requestController: AbortController | null = null;
let pollTimer: ReturnType<typeof setTimeout> | undefined;
function stopPolling() { pollController?.abort(); pollController = null; if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; }
async function pollSync(ids: string[]) {
stopPolling();
const controller = new AbortController(); pollController = controller;
try {
for (let attempt = 0; attempt < 120 && !controller.signal.aborted; attempt += 1) {
const next = await Promise.all(ids.map((id) => syncJobStatus(id, controller.signal)));
if (controller.signal.aborted) return;
if (next.some((job) => job === null)) { busy = false; failure = 'A synchronization job disappeared before completion; no report retry was performed.'; return; }
syncJobs = next as SyncJob[];
if (syncJobs.every((job) => syncTerminal(job.status))) {
busy = false;
if (syncJobs.length > 0 && syncJobs.every((job) => job.status === 'succeeded') && syncSkipped.every((entry) => entry.reason === 'authorization_required')) await ask(submitted, true);
return;
}
await new Promise<void>((resolve) => {
const done = () => { if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; controller.signal.removeEventListener('abort', done); resolve(); };
pollTimer = setTimeout(done, 1_000); controller.signal.addEventListener('abort', done, { once: true });
});
}
if (!controller.signal.aborted) { busy = false; failure = 'Synchronization is still running; retry the report after the queued jobs finish.'; }
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization status unavailable.'; } }
finally { if (pollController === controller) pollController = null; }
}
onDestroy(() => { stopPolling(); enqueueController?.abort(); enqueueController = null; requestController?.abort(); requestController = null; });
async function cancelSync(id: string) {
try { const job = await cancelSyncJob(id); if (job) syncJobs = syncJobs.map((entry) => entry.id === job.id ? job : entry); }
catch (error) { failure = error instanceof Error ? error.message : 'Could not cancel synchronization.'; }
}
async function ask(text = prompt, resume = false) {
if (!text.trim() || busy) return;
busy = true; failure = ''; submitted = text;
requestController?.abort(); const controller = new AbortController(); requestController = controller;
if (!resume || !requestId) requestId = crypto.randomUUID();
sessionStorage.setItem('eve-pending', JSON.stringify({ text, requestId }));
try {
const response = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: text, requestId }) });
const response = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: text, requestId }), signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Request failed');
message = body.message; operation = body.operation ?? null;
if (!operation || operation.status === 'ready') sessionStorage.removeItem('eve-pending');
} catch (error) { failure = error instanceof Error ? error.message : 'Request failed'; }
finally { busy = false; }
} catch (error) { if (!controller.signal.aborted) failure = error instanceof Error ? error.message : 'Request failed'; }
finally { if (requestController === controller) requestController = null; busy = false; }
}
async function sync() {
busy = true; failure = '';
stopPolling(); enqueueController?.abort(); const controller = new AbortController(); enqueueController = controller; busy = true; failure = '';
try {
const response = await fetch('/api/sync', { method: 'POST' });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Synchronization failed');
busy = false; await ask(submitted, true);
} catch (error) { failure = error instanceof Error ? error.message : 'Synchronization failed'; }
finally { busy = false; }
const { response, body } = await enqueueSync(controller.signal);
if ('jobs' in body) { syncJobs = body.jobs; syncSkipped = body.skipped; }
if (!response.ok || !('jobs' in body)) throw new Error(('error' in body && body.error) || (syncSkipped.some((entry) => entry.reason === 'queue_full') ? 'Synchronization queue is full.' : 'Synchronization could not be queued.'));
if (syncJobs.length > 0) await pollSync(syncJobs.map((job) => job.id));
else busy = false;
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization failed'; } }
finally { if (enqueueController === controller) enqueueController = null; }
}
onMount(() => {
try {
@@ -54,6 +92,7 @@
</form>
<div class="row"><button class="quiet" disabled={busy} onclick={() => ask('/value')}>Estimate all assets</button><button class="quiet" disabled={busy} onclick={() => ask('/assets')}>Browse assets</button></div>
{#if failure}<p class="error" role="alert">{failure}</p>{/if}
{#if syncJobs.length || syncSkipped.length}<section class="panel" aria-live="polite"><h2>Synchronization status</h2>{#each syncJobs as job}<p class="row spread"><span>Character {job.characterId}: {job.status}{job.error ? ` — ${job.error}` : ''}</span>{#if job.status === 'queued' || job.status === 'running'}<button class="quiet" onclick={() => cancelSync(job.id)}>Cancel</button>{/if}</p>{/each}{#each syncSkipped as entry}<p class="muted">Character {entry.characterId}: {entry.reason === 'authorization_required' ? 'authorization required' : entry.reason === 'queue_full' ? 'queue full' : 'enqueue failed'}</p>{/each}</section>{/if}
<div aria-live="polite" aria-busy={busy}>
{#if submitted}<p class="message">{submitted}</p>{/if}
{#if message}<p>{message}</p>{/if}
+9 -2
View File
@@ -6,14 +6,21 @@ import { configuration } from '$lib/server/config';
import { boundedJson, protectMutation } from '$lib/server/http';
import { JevIntentRouter, ManualIntentRouter } from '$lib/server/assistant/intent';
import { answerAssets } from '$lib/server/assistant/service';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => {
const config = configuration();
// Bound origin/session work by the existing anonymous IP bucket first. The
// normal authenticated user-keyed limiter is restored after resolution.
protectMutation(event, `chat:${event.getClientAddress()}`);
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
}
const current = await session(event);
protectMutation(event, `chat:${current?.userId ?? event.getClientAddress()}`);
if (current) protectMutation(event, `chat:${current.userId}`);
const parsed = schema.safeParse(await boundedJson(event));
if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
const { message, requestId } = parsed.data;
const config = configuration();
const manual = new ManualIntentRouter();
// Explicit commands remain reliable even when JEV is configured/unavailable.
const router = message === '/assets' || message === '/value' || !config.jevKey ? manual : new JevIntentRouter({ apiKey: config.jevKey });
+4 -1
View File
@@ -3,8 +3,11 @@ import type { RequestHandler } from './$types';
import { configuration } from '$lib/server/config';
import { session } from '$lib/server/application';
import { applicationJwt } from '$lib/server/security';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const GET: RequestHandler = async (event) => {
const config = configuration(); const current = await session(event);
const config = configuration();
if (!capabilityEnabled('session_token', config.capabilityAllowlist)) return json({ error: capabilityDisabledReason('session_token') }, { status: 503 });
const current = await session(event);
if (!current || config.mode === 'demo') return json({ error: 'Sign in required' }, { status: 401 });
const token = await applicationJwt({ ...current, issuer: config.issuer, privateKey: config.privateKey });
return json({ token }, { headers: { 'cache-control': 'no-store' } });
+18 -4
View File
@@ -2,13 +2,27 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { session, synchronize } from '$lib/server/application';
import { protectMutation } from '$lib/server/http';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
import { configuration } from '$lib/server/config';
import { hashSecret } from '$lib/server/security';
import { SESSION_COOKIE } from '$lib/server/application';
export const POST: RequestHandler = async (event) => {
const config = configuration();
// Bound origin/session work by the existing anonymous IP bucket first. The
// normal authenticated user-keyed limiter is restored after resolution.
protectMutation(event, `sync:${event.getClientAddress()}`, 6);
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
}
const current = await session(event);
protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6);
if (current) protectMutation(event, `sync:${current.userId}`, 6);
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try {
const result = await synchronize(current.userId);
if (result.failed.length) return json({ ...result, error: `${result.failed.length} character(s) could not synchronize. Previous complete snapshots were preserved; check Characters.` }, { status: 503 });
return json(result);
const cookie = event.cookies.get(SESSION_COOKIE);
if (!cookie) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
const result = await synchronize(current.userId, hashSecret(cookie));
const failures = result.skipped.filter((entry) => entry.reason !== 'authorization_required');
const status = result.queued > 0 ? 202 : failures.some((entry) => entry.reason === 'queue_full') ? 429 : failures.length > 0 ? 503 : 200;
return json(result, { status });
} catch { return json({ error: 'Synchronization unavailable. Check backend configuration and try again.' }, { status: 503 }); }
};
+27
View File
@@ -0,0 +1,27 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { live, session, SESSION_COOKIE } from '$lib/server/application';
import { hashSecret } from '$lib/server/security';
import { configuration } from '$lib/server/config';
import { boundedJson, protectMutation } from '$lib/server/http';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const POST: RequestHandler = async (event) => {
const config = configuration();
protectMutation(event, `sync-cancel:${event.getClientAddress()}`, 6);
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
const current = await session(event);
if (current) protectMutation(event, `sync-cancel:${current.userId}`, 6);
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
const body = await boundedJson(event) as { jobId?: unknown } | null;
const jobId = body && typeof body.jobId === 'string' ? body.jobId : '';
if (!jobId || jobId.length > 128) return json({ error: 'A sync job is required.' }, { status: 400 });
const cookie = event.cookies.get(SESSION_COOKIE);
if (!cookie) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try {
const result = await live().store.cancelSyncJob(current.userId, jobId, hashSecret(cookie));
return result ? json(result) : json({ error: 'Sync job not found.' }, { status: 404 });
} catch {
return json({ error: 'Synchronization cancellation unavailable.' }, { status: 503 });
}
};
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { live, session } from '$lib/server/application';
import { configuration } from '$lib/server/config';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const GET: RequestHandler = async (event) => {
const config = configuration();
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
const current = await session(event);
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
const jobId = event.url.searchParams.get('job');
if (!jobId || jobId.length > 128) return json({ error: 'A sync job is required.' }, { status: 400 });
try {
const job = await live().store.syncJob(current.userId, jobId);
return job ? json(job) : json({ error: 'Sync job not found.' }, { status: 404 });
} catch {
return json({ error: 'Synchronization status unavailable.' }, { status: 503 });
}
};
+11 -1
View File
@@ -1,3 +1,13 @@
import type { PageServerLoad } from './$types';
import { assetReport, session } from '$lib/server/application';
export const load: PageServerLoad = async (event) => ({ result: await assetReport((await session(event))?.userId ?? null) });
import { configuration } from '$lib/server/config';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
export const load: PageServerLoad = async (event) => {
const config = configuration();
for (const capability of ['asset_search', 'asset_valuation'] as const) {
if (!capabilityEnabled(capability, config.capabilityAllowlist)) {
return { result: { status: 'unavailable' as const, reason: capabilityDisabledReason(capability) } };
}
}
return { result: await assetReport((await session(event))?.userId ?? null, 'asset_search') };
};
+48 -8
View File
@@ -1,20 +1,60 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { invalidateAll } from '$app/navigation';
import AssetResult from '$lib/components/AssetResult.svelte';
import { cancelSyncJob, enqueueSync, syncJobStatus, syncTerminal, type SyncJob, type SyncSkip } from '$lib/client/sync-queue';
let { data } = $props();
let busy = $state(false); let failure = $state('');
async function sync() {
busy = true; failure = '';
let jobs = $state<SyncJob[]>([]); let skipped = $state<SyncSkip[]>([]);
let pollController: AbortController | null = null;
let enqueueController: AbortController | null = null;
let pollTimer: ReturnType<typeof setTimeout> | undefined;
function stopPolling() { pollController?.abort(); pollController = null; if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; }
async function poll(ids: string[]) {
stopPolling();
const controller = new AbortController(); pollController = controller;
try {
const response = await fetch('/api/sync', { method: 'POST' });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Synchronization failed');
await invalidateAll();
} catch (error) { failure = error instanceof Error ? error.message : 'Synchronization failed'; }
finally { busy = false; }
for (let attempt = 0; attempt < 120 && !controller.signal.aborted; attempt += 1) {
const next = await Promise.all(ids.map((id) => syncJobStatus(id, controller.signal)));
if (controller.signal.aborted) return;
if (next.some((job) => job === null)) { busy = false; failure = 'A synchronization job disappeared before completion; no report retry was performed.'; return; }
jobs = next as SyncJob[];
if (jobs.every((job) => syncTerminal(job.status))) {
busy = false;
if (jobs.length > 0 && jobs.every((job) => job.status === 'succeeded') && skipped.every((entry) => entry.reason === 'authorization_required')) await invalidateAll();
return;
}
await new Promise<void>((resolve) => {
const done = () => { if (pollTimer !== undefined) clearTimeout(pollTimer); pollTimer = undefined; controller.signal.removeEventListener('abort', done); resolve(); };
pollTimer = setTimeout(done, 1_000); controller.signal.addEventListener('abort', done, { once: true });
});
}
if (!controller.signal.aborted) { busy = false; failure = 'Synchronization is still running; this page will retain its queued status.'; }
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization status unavailable.'; } }
finally { if (pollController === controller) pollController = null; }
}
onDestroy(() => { stopPolling(); enqueueController?.abort(); enqueueController = null; });
async function sync() {
stopPolling(); enqueueController?.abort(); const controller = new AbortController(); enqueueController = controller; busy = true; failure = '';
try {
const { response, body } = await enqueueSync(controller.signal);
if ('jobs' in body) { jobs = body.jobs; skipped = body.skipped; }
if (!response.ok || !('jobs' in body)) throw new Error(('error' in body && body.error) || (skipped.some((entry) => entry.reason === 'queue_full') ? 'Synchronization queue is full.' : 'Synchronization could not be queued.'));
if (jobs.length > 0) await poll(jobs.map((job) => job.id));
else busy = false;
} catch (error) { if (!controller.signal.aborted) { busy = false; failure = error instanceof Error ? error.message : 'Synchronization failed'; } }
finally { if (enqueueController === controller) enqueueController = null; }
}
async function cancel(id: string) {
try { const job = await cancelSyncJob(id); if (job) jobs = jobs.map((entry) => entry.id === job.id ? job : entry); }
catch (error) { failure = error instanceof Error ? error.message : 'Could not cancel synchronization.'; }
}
function skipLabel(reason: SyncSkip['reason']): string {
return reason === 'authorization_required' ? 'authorization required' : reason === 'queue_full' ? 'queue full' : 'enqueue failed';
}
</script>
<div class="row spread"><div><p class="eyebrow">SHARED DOMAIN VIEW</p><h1>Assets</h1></div><button class="quiet" disabled={busy} onclick={sync}>{busy ? 'Synchronizing…' : 'Synchronize characters'}</button></div>
<p class="muted">All connected characters. The same deterministic report used by the assistant.</p>
{#if failure}<p class="error" role="alert">{failure}</p>{/if}
{#if jobs.length || skipped.length}<section class="panel" aria-live="polite"><h2>Synchronization status</h2>{#each jobs as job}<p class="row spread"><span>Character {job.characterId}: {job.status}{job.error ? ` — ${job.error}` : ''}</span>{#if job.status === 'queued' || job.status === 'running'}<button class="quiet" onclick={() => cancel(job.id)}>Cancel</button>{/if}</p>{/each}{#each skipped as entry}<p class="muted">Character {entry.characterId}: {skipLabel(entry.reason)}</p>{/each}</section>{/if}
<AssetResult result={data.result} onSync={sync} />
+88 -8
View File
@@ -4,21 +4,101 @@ import { configuration } from '$lib/server/config';
import { live, session, SESSION_COOKIE } from '$lib/server/application';
import { hashSecret, pkceChallenge, randomSecret } from '$lib/server/security';
import { protectMutation } from '$lib/server/http';
import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release-gate';
import { CAPABILITY_DEFINITIONS, SERVER_SCOPE_ALLOWLIST, validateCapabilityScopes } from '$lib/domain/capabilities';
import type { Capability } from '$lib/domain/model';
const CONSENT_INTENTS = new Set(['initial_login', 'link_character', 'upgrade_dataset', 'reconnect']);
const CONSENT_CAPABILITIES = new Set<Capability>([
'asset_search',
'asset_valuation',
'inventory_locations',
'wallet_balance',
'manufacturing_opportunities',
]);
function formString(value: FormDataEntryValue | null): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export const POST: RequestHandler = async (event) => {
const config = configuration();
protectMutation(event, `oauth:${event.getClientAddress()}`, 10);
if (!capabilityEnabled('identity', config.capabilityAllowlist)) error(503, capabilityDisabledReason('identity'));
if (config.mode === 'demo') error(400, 'EVE authentication is disabled in fixture mode');
let destination: string;
try {
const { store, cipher, sso } = live();
const form = await event.request.formData();
const capabilityValue = formString(form.get('capability')) ?? 'asset_search';
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 === 'inventory_locations' || capability === 'wallet_balance' || capability === 'manufacturing_opportunities')
&& !capabilityEnabled(capability, config.capabilityAllowlist)) {
error(503, capabilityDisabledReason(capability));
}
const current = await session(event);
const state = randomSecret(); const binding = randomSecret(); const verifier = randomSecret();
const rawCookie = event.cookies.get(SESSION_COOKIE);
const targetValue = formString(form.get('targetCharacterId'));
const targetCharacterId = targetValue === undefined ? undefined : Number(targetValue);
if (targetCharacterId !== undefined && (!Number.isSafeInteger(targetCharacterId) || targetCharacterId <= 0)) {
error(400, 'Invalid character selection');
}
const requestedIntent = formString(form.get('intent'));
if (requestedIntent !== undefined && !CONSENT_INTENTS.has(requestedIntent)) error(400, 'Invalid consent intent');
const intent: 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' = (requestedIntent as 'initial_login' | 'link_character' | 'upgrade_dataset' | 'reconnect' | undefined)
?? (current ? targetCharacterId === undefined ? 'link_character' : 'reconnect' : 'initial_login');
if (current === null && intent !== 'initial_login') error(401, 'An authenticated session is required for this action');
if (intent === 'upgrade_dataset' || intent === 'reconnect') {
if (!current || targetCharacterId === undefined) error(400, 'A linked character is required for this action');
}
const { store, cipher, sso } = live();
const owned = current ? await store.characters(current.userId) : [];
const target = targetCharacterId === undefined ? undefined : owned.find((character) => character.id === targetCharacterId);
if (targetCharacterId !== undefined && !target) error(403, 'Character is not linked to this application user');
const approvedExistingScopes = target?.scopes.filter((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope)) ?? [];
const requestedScopes = [...new Set([
...CAPABILITY_DEFINITIONS[capability].requiredScopes,
...approvedExistingScopes,
])];
const validation = validateCapabilityScopes(capability, requestedScopes, approvedExistingScopes);
if (!validation.valid) error(400, 'Requested consent scopes are not permitted');
const state = randomSecret();
const binding = randomSecret();
const verifier = randomSecret();
const stateHash = hashSecret(state);
const encryptedVerifier = await cipher.seal({ verifier, sessionHash: current ? hashSecret(event.cookies.get(SESSION_COOKIE) ?? '') : null }, `oauth:${stateHash}`);
await store.createOAuth({ stateHash, bindingHash: hashSecret(binding), ...(current ? { userId: current.userId } : {}), encryptedVerifier, expiresAt: Date.now() + 600_000, returnTo: '/' });
destination = await sso.authorizationUrl(state, pkceChallenge(verifier));
event.cookies.set('eve_oauth_binding', binding, { path: '/auth/eve', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 600 });
} catch { error(503, 'EVE connection unavailable. Check server configuration.'); }
redirect(303, destination);
const initiatingSessionHash = current && rawCookie ? hashSecret(rawCookie) : undefined;
const encryptedVerifier = await cipher.seal({
verifier,
sessionHash: initiatingSessionHash ?? null,
}, `oauth:${stateHash}`);
await store.createOAuth({
stateHash,
bindingHash: hashSecret(binding),
...(current ? { userId: current.userId } : {}),
encryptedVerifier,
expiresAt: Date.now() + 600_000,
returnTo: '/characters',
intent,
capability,
requestedScopes,
approvedExistingScopes,
...(initiatingSessionHash ? { initiatingSessionHash } : {}),
...(targetCharacterId === undefined ? {} : { targetCharacterId }),
});
destination = await sso.authorizationUrl(state, pkceChallenge(verifier), requestedScopes);
event.cookies.set('eve_oauth_binding', binding, {
path: '/auth/eve', httpOnly: true, sameSite: 'lax',
secure: config.origin.startsWith('https:'), maxAge: 600,
});
} catch (caught) {
// SvelteKit HTTP errors should retain their fixed status/message.
if (caught && typeof caught === 'object' && 'status' in caught) throw caught;
error(503, 'EVE connection unavailable. Check server configuration.');
}
redirect(303, destination!);
};
+46 -11
View File
@@ -3,22 +3,33 @@ import type { RequestHandler } from './$types';
import { live, session, SESSION_COOKIE } from '$lib/server/application';
import { assertOAuthSessionBinding, hashSecret, randomSecret } from '$lib/server/security';
import { oauthFailureDiagnostic, type OAuthPhase } from '$lib/server/oauth-diagnostics';
import { configuration } from '$lib/server/config';
import { capabilityEnabled } from '$lib/server/release-gate';
import { SERVER_SCOPE_ALLOWLIST } from '$lib/domain/capabilities';
function safeReturnTo(value: string): string {
return value === '/characters' ? value : '/characters';
}
export const GET: RequestHandler = async (event) => {
const config = configuration();
if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled');
const binding = event.cookies.get('eve_oauth_binding');
event.cookies.delete('eve_oauth_binding', { path: '/auth/eve' });
let phase: OAuthPhase = 'state_input';
let returnTo = '/characters';
try {
const state = event.url.searchParams.get('state');
if (!state) throw new Error('Missing OAuth state');
if (state.length > 200) throw new Error('Invalid OAuth state');
if (!binding) throw new Error('Missing OAuth binding cookie');
phase = 'configuration';
const { config, store, cipher, sso } = live();
const { store, cipher, sso } = live();
const stateHash = hashSecret(state);
phase = 'state_consumption';
const pending = await store.consumeOAuth(stateHash, hashSecret(binding));
if (!pending || pending.expiresAt <= Date.now()) throw new Error('Expired OAuth state');
returnTo = safeReturnTo(pending.returnTo);
phase = 'provider_response';
const code = event.url.searchParams.get('code');
if (event.url.searchParams.has('error') || !code || code.length > 4096) throw new Error('Authorization denied');
@@ -27,25 +38,49 @@ export const GET: RequestHandler = async (event) => {
phase = 'session_binding';
const current = await session(event);
const oldCookie = event.cookies.get(SESSION_COOKIE);
assertOAuthSessionBinding({ expectedUserId: pending.userId, expectedSessionHash: proof.sessionHash, currentUserId: current?.userId ?? null, currentCookie: oldCookie });
assertOAuthSessionBinding({
expectedUserId: pending.userId,
expectedSessionHash: pending.initiatingSessionHash ?? proof.sessionHash,
currentUserId: current?.userId ?? null,
currentCookie: oldCookie,
});
phase = 'exchange';
const grant = await sso.exchange(code, proof.verifier);
if (grant.scopes.some((scope) => !SERVER_SCOPE_ALLOWLIST.includes(scope)) || new Set(grant.scopes).size !== grant.scopes.length) {
throw new Error('Invalid EVE SSO granted scopes');
}
if (pending.targetCharacterId !== undefined && pending.targetCharacterId !== grant.characterId) {
throw new Error('OAuth character mismatch');
}
const cookie = randomSecret();
phase = 'token_encryption';
const encryptedTokens = await cipher.seal(grant, `character:${grant.characterId}`);
phase = 'login_persistence';
await store.completeLogin({
...(pending.userId ? { userId: pending.userId } : {}), characterId: grant.characterId, name: grant.name, ownerHash: grant.ownerHash, scopes: grant.scopes,
encryptedTokens, tokenExpiresAt: grant.expiresAt,
sessionHash: hashSecret(cookie), sessionExpiresAt: Date.now() + 7 * 86400_000
...(pending.userId ? { userId: pending.userId } : {}),
characterId: grant.characterId,
name: grant.name,
ownerHash: grant.ownerHash,
scopes: grant.scopes,
encryptedTokens,
tokenExpiresAt: grant.expiresAt,
sessionHash: hashSecret(cookie),
sessionExpiresAt: Date.now() + 7 * 86400_000,
intent: pending.intent,
capability: pending.capability,
requestedScopes: pending.requestedScopes,
approvedExistingScopes: pending.approvedExistingScopes,
...(pending.initiatingSessionHash ? { initiatingSessionHash: pending.initiatingSessionHash } : {}),
...(pending.targetCharacterId === undefined ? {} : { targetCharacterId: pending.targetCharacterId }),
});
phase = 'session_revocation';
if (oldCookie) await store.deleteSession(hashSecret(oldCookie));
phase = 'cookie_issue';
event.cookies.set(SESSION_COOKIE, cookie, { path: '/', httpOnly: true, sameSite: 'lax', secure: config.origin.startsWith('https:'), maxAge: 7 * 86400 });
} catch (error) {
console.error(JSON.stringify(oauthFailureDiagnostic(phase, error)));
event.cookies.set(SESSION_COOKIE, cookie, {
path: '/', httpOnly: true, sameSite: 'lax',
secure: config.origin.startsWith('https:'), maxAge: 7 * 86400,
});
} catch (caught) {
console.error(JSON.stringify(oauthFailureDiagnostic(phase, caught)));
redirect(303, '/characters?auth=failed');
}
redirect(303, '/');
redirect(303, returnTo);
};
+32 -4
View File
@@ -1,6 +1,34 @@
import type { PageServerLoad } from './$types';
import { charactersFor, session } from '$lib/server/application';
export const load: PageServerLoad = async (event) => ({
characters: await charactersFor((await session(event))?.userId ?? null),
error: event.url.searchParams.get('auth') === 'failed' ? 'EVE connection failed or was cancelled. The login state may have expired, or this character may belong to another application user. Please try again.' : null
});
import { configuration } from '$lib/server/config';
import { capabilityEnabled, capabilityDisabledReason } from '$lib/server/release-gate';
export const load: PageServerLoad = async (event) => {
const config = configuration();
const identityEnabled = capabilityEnabled('identity', config.capabilityAllowlist);
if (!identityEnabled) {
return {
characters: [],
identityEnabled: false,
walletEnabled: false,
locationEnabled: false,
error: capabilityDisabledReason('identity'),
};
}
const current = await session(event);
const authStatus = event.url.searchParams.get('auth');
const error = authStatus === 'failed'
? 'The character action failed or was cancelled. The login state may have expired, the character may be ineligible, or it may belong to another application user.'
: authStatus === 'required'
? 'Sign in with the primary character before managing linked characters.'
: authStatus === 'disabled'
? capabilityDisabledReason('identity')
: null;
return {
characters: await charactersFor(current?.userId ?? null),
identityEnabled: true,
walletEnabled: capabilityEnabled('wallet_balance', config.capabilityAllowlist),
locationEnabled: capabilityEnabled('inventory_locations', config.capabilityAllowlist),
error,
};
};
+61 -7
View File
@@ -1,17 +1,71 @@
<script lang="ts">
import LiveCharacters from '$lib/components/LiveCharacters.svelte';
let { data } = $props();
const datasetLabel = (dataset: string) => dataset.replaceAll('_', ' ');
const assetsScope = 'esi-assets.read_assets.v1';
const structuresScope = 'esi-universe.read_structures.v1';
const locationState = (character: any) => character.authorizationStatus !== 'active' || !character.scopes.includes(assetsScope)
? 'reduced' : character.scopes.includes(structuresScope) ? 'granted' : 'missing';
</script>
<p class="eyebrow">APPLICATION-USER AGGREGATION</p><h1>Characters</h1>
<p class="muted">Connect each character explicitly. ESI does not expose all characters on an EVE account.</p>
{#if data.mode !== 'demo'}<form method="POST" action="/auth/eve"><button>Connect EVE Character</button></form>{/if}
<p class="muted">Connect each character explicitly. One primary character is the only signed-out login; linked characters are data-only until you switch the primary while authenticated.</p>
{#if data.mode !== 'demo' && data.identityEnabled}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="asset_search" />
<button>Connect EVE Character</button>
</form>
{/if}
{#if data.error}<p class="error" role="alert">{data.error}</p>{/if}
{#if data.userId && data.convexUrl && data.mode !== 'demo'}<LiveCharacters />{/if}
{#each data.characters as character (character.id)}
<section class="panel"><div class="row spread"><h2>{character.name}</h2><span class="muted">{character.assets.status}</span></div>
<small>Character {character.id}</small>
<p class="muted">Assets synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}<br />Granted scopes: {character.scopes.join(', ') || 'None'}</p>
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
<section class="panel">
<div class="row spread"><h2>{character.name}</h2><span class="muted">{character.loginRole === 'data_only' ? 'DATA-ONLY' : 'PRIMARY'} · {character.authorizationStatus ?? 'active'}</span></div>
<small>Character {character.id} · grant r{character.grantRevision ?? 1} · credential v{character.credentialVersion ?? 1} · epoch {character.authorizationEpoch ?? 1}</small>
<p class="muted">Assets: {character.assets.status} · synchronized: {character.assets.syncedAt ? new Date(character.assets.syncedAt).toISOString() : 'Not yet'}<br />Granted scopes: {character.scopes.join(', ') || 'None'}</p>
{#if character.datasets}
<p class="muted">Datasets: {character.datasets.map((dataset) => `${datasetLabel(dataset.dataset)}=${dataset.status}`).join(' · ')}</p>
{/if}
{#if character.assets.error}<p class="warning">{character.assets.error}</p>{/if}
<p class="muted">Inventory locations: <strong>{data.locationEnabled ? locationState(character) : 'unavailable'}</strong>{#if data.locationEnabled && locationState(character) === 'missing'} · Structure consent is required for private location evidence.{/if}</p>
{#if data.mode !== 'demo' && data.identityEnabled}
<div class="row">
{#if character.loginRole === 'data_only' && character.authorizationStatus === 'active'}
<form method="POST" action="/characters/primary"><input type="hidden" name="characterId" value={character.id} /><button class="quiet">Set as primary</button></form>
{/if}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="asset_search" />
<input type="hidden" name="intent" value="reconnect" />
<input type="hidden" name="targetCharacterId" value={character.id} />
<button class="quiet">{character.authorizationStatus === 'active' ? 'Refresh authorization' : 'Reconnect'}</button>
</form>
{#if character.authorizationStatus === 'active' && data.locationEnabled && character.scopes.includes(assetsScope) && !character.scopes.includes(structuresScope)}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="inventory_locations" />
<input type="hidden" name="intent" value="upgrade_dataset" />
<input type="hidden" name="targetCharacterId" value={character.id} />
<button class="quiet">Authorize private locations</button>
</form>
{/if}
{#if character.authorizationStatus === 'active' && data.walletEnabled}
<form method="POST" action="/auth/eve">
<input type="hidden" name="capability" value="wallet_balance" />
<input type="hidden" name="intent" value="upgrade_dataset" />
<input type="hidden" name="targetCharacterId" value={character.id} />
<button class="quiet">Authorize wallet</button>
</form>
{/if}
{#if character.loginRole === 'data_only' && character.authorizationStatus !== 'revoked'}
<details>
<summary>Disconnect character</summary>
<p class="muted">This immediately blocks local datasets, discards the stored credential, and cancels pending local work. Reconnecting requires EVE authorization again.</p>
<form method="POST" action="/characters/invalidate">
<input type="hidden" name="characterId" value={character.id} />
<button class="quiet">Confirm disconnect</button>
</form>
</details>
{/if}
</div>
{/if}
</section>
{:else}<section class="panel"><p>No connected characters. Your first verified EVE login creates your application user; further connections attach to that user.</p></section>{/each}
{:else}<section class="panel"><p>No connected characters. Your first verified EVE login creates the primary application character.</p></section>{/each}
<p><a href="/assets">Open asset report →</a></p>
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { live, session, SESSION_COOKIE } from '$lib/server/application';
import { hashSecret } from '$lib/server/security';
import { protectMutation } from '$lib/server/http';
import { configuration } from '$lib/server/config';
import { capabilityEnabled } from '$lib/server/release-gate';
export const POST: RequestHandler = async (event) => {
const config = configuration();
protectMutation(event, `character-invalidate:${event.getClientAddress()}`, 20);
if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled');
const current = await session(event);
if (!current) redirect(303, '/characters?auth=required');
const form = await event.request.formData();
const value = form.get('characterId');
const characterId = typeof value === 'string' ? Number(value) : NaN;
if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed');
const cookie = event.cookies.get(SESSION_COOKIE);
if (!cookie) redirect(303, '/characters?auth=required');
try {
await live().store.invalidateAuthorization(current.userId, characterId, hashSecret(cookie));
} catch {
redirect(303, '/characters?auth=failed');
}
redirect(303, '/characters');
};
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { configuration } from '$lib/server/config';
import { live, session, SESSION_COOKIE } from '$lib/server/application';
import { hashSecret } from '$lib/server/security';
import { protectMutation } from '$lib/server/http';
import { capabilityEnabled } from '$lib/server/release-gate';
export const POST: RequestHandler = async (event) => {
const config = configuration();
protectMutation(event, `character-primary:${event.getClientAddress()}`, 20);
if (!capabilityEnabled('identity', config.capabilityAllowlist)) redirect(303, '/characters?auth=disabled');
const current = await session(event);
if (!current) redirect(303, '/characters?auth=required');
const form = await event.request.formData();
const value = form.get('characterId');
const characterId = typeof value === 'string' ? Number(value) : NaN;
if (!Number.isSafeInteger(characterId) || characterId <= 0) redirect(303, '/characters?auth=failed');
const cookie = event.cookies.get(SESSION_COOKIE);
if (!cookie) redirect(303, '/characters?auth=required');
try {
await live().store.setPrimary(current.userId, characterId, hashSecret(cookie));
} catch {
redirect(303, '/characters?auth=failed');
}
redirect(303, '/characters');
};
+221
View File
@@ -0,0 +1,221 @@
import { describe, expect, test } from 'bun:test';
import {
CAPABILITY_DEFINITIONS,
CAPABILITY_REQUIREMENTS,
ESI_ASSETS_SCOPE,
ESI_STRUCTURES_SCOPE,
ESI_WALLET_SCOPE,
gateCapabilityWithAuthorization,
validateCapabilityScopes,
validateResourceCapabilityScopes,
validateConsentCallback,
validateDatasetJobFence,
validateCompleteReportPins,
validateInventoryCursorUse,
validateInventoryPageResult,
} from '../src/lib/domain/capabilities';
import { absentLocation, afterEndInventoryCursorState, afterEndInventoryResult, ambiguousType, appendDatasetState, authorization, borrowedInventoryCursorLookup, changedQueryInventoryCursorLookup, changedRevisionInventoryCursorLookup, consent, datasetState, inventoryCursor, inventoryCursorLookup, inventoryCursorState, inventoryQuery, inventoryResult, approvedMoneyPolicy, singletonRows, systemListedStationPlaceholder, syntheticResource, systemJob, verifiedNamelessStation, walletResult } from '../fixtures/contracts/t02';
describe('T02 typed contract fixtures', () => {
test('separates snapshot generations from append checkpoints and unknown upstream time', () => {
expect(datasetState.coverage).toEqual({ kind: 'snapshot', generation: 'assets-g7', complete: true });
expect(datasetState.fetchedAt).toBeGreaterThan(0);
expect(datasetState.upstreamEffectiveAt).toBeNull();
expect(appendDatasetState.coverage.kind).toBe('append');
if (appendDatasetState.coverage.kind === 'append') {
expect(appendDatasetState.coverage.checkpoint).toBe('journal-from-90000000000000001');
}
expect(typeof authorization.grantRevision).toBe('number');
expect(typeof authorization.credentialVersion).toBe('number');
});
test('binds consent to one session, capability, scopes and selected resources', () => {
expect(consent.state).toBe('pending');
expect(consent.sessionId).toBe('session-fixture-1');
expect(consent.resourceRequests[0]?.resource.authorizationRevision).toBe(7);
expect(consent.resourceRequests[0]?.requestedScopes).toEqual([ESI_ASSETS_SCOPE]);
});
test('keeps system job authority distinct from user resources', () => {
expect(systemJob.authority).toBe('system');
expect(systemJob.owner).toEqual({ kind: 'system', service: 'reference' });
expect(systemJob.publicationFence.resources).toHaveLength(0);
expect(validateDatasetJobFence(systemJob)).toEqual({ valid: true });
});
test('pins inventory cursors and reports to authorized resources and all matching rows', () => {
expect(inventoryQuery.cursor).toBe(inventoryCursor);
expect(inventoryCursorState.token).toBe(inventoryCursor.token);
expect(inventoryCursorState.status).toBe('active');
expect(inventoryCursorState.position).toEqual({ offset: 0, lastItemId: null });
expect(inventoryQuery.sort.tieBreaker).toBe('item_id');
expect(inventoryQuery.authorizationRevisions['character:90000000000000001']).toBe(7);
expect(inventoryCursorState.pin.callerUserId).toBe('fixture-user-a');
expect(validateInventoryCursorUse(inventoryCursorState, inventoryCursorLookup)).toEqual({ valid: true });
expect(validateInventoryCursorUse(inventoryCursorState, borrowedInventoryCursorLookup)).toEqual({ valid: false, reason: 'caller_mismatch' });
expect(validateInventoryCursorUse(inventoryCursorState, changedQueryInventoryCursorLookup)).toEqual({ valid: false, reason: 'query_mismatch' });
expect(validateInventoryCursorUse(inventoryCursorState, changedRevisionInventoryCursorLookup)).toEqual({ valid: false, reason: 'authorization_mismatch' });
expect(validateInventoryCursorUse(inventoryCursorState, { ...inventoryCursorLookup, selectedResources: [{ ...syntheticResource, principal: { kind: 'character', ownerUserId: 'fixture-user-b', id: syntheticResource.principal.id } }] })).toEqual({ valid: false, reason: 'resource_mismatch' });
expect(validateInventoryCursorUse(inventoryCursorState, { ...inventoryCursorLookup, selectedResources: [syntheticResource, syntheticResource] })).toEqual({ valid: false, reason: 'resource_mismatch' });
expect(validateInventoryCursorUse({ ...inventoryCursorState, position: { offset: -1, lastItemId: null } } as unknown as typeof inventoryCursorState, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' });
expect(validateInventoryCursorUse({ ...inventoryCursorState, position: { offset: 1, lastItemId: '01' } }, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' });
expect(validateInventoryCursorUse({ ...inventoryCursorState, position: { offset: 1, lastItemId: '1\n' } }, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' });
expect(validateInventoryCursorUse({ ...inventoryCursorState, status: 'unexpected' } as unknown as typeof inventoryCursorState, inventoryCursorLookup)).toEqual({ valid: false, reason: 'malformed_state' });
expect(inventoryCursorState.pin.authorizationEpochs['character:90000000000000001']).toBe(1);
expect(inventoryResult.status).toBe('ready');
if (inventoryResult.status === 'ready') {
expect(inventoryResult.summary.scope).toBe('all_matching_rows');
expect(inventoryResult.pageState).toBe('initial');
expect(inventoryResult.provenance.input.snapshotGenerations.assets).toBe('assets-g7');
expect(inventoryResult.provenance.input.referenceVersion).toBe('reference-g1');
expect(validateInventoryPageResult(inventoryResult)).toEqual({ valid: true });
expect(validateInventoryPageResult({ ...inventoryResult, pageState: 'unexpected' } as unknown as typeof inventoryResult)).toEqual({ valid: false, reason: 'invalid_page_state' });
expect(validateInventoryPageResult({ ...inventoryResult, status: 'unexpected' } as unknown as typeof inventoryResult)).toEqual({ valid: false, reason: 'invalid_page_state' });
expect(validateCompleteReportPins(inventoryResult.provenance.input)).toEqual({ valid: true });
expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, selectedResources: [syntheticResource, syntheticResource] })).toEqual({ valid: false, reason: 'duplicate_resource' });
expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, datasetPins: [inventoryResult.provenance.input.datasetPins[0]!, inventoryResult.provenance.input.datasetPins[0]!] })).toEqual({ valid: false, reason: 'duplicate_pin' });
expect(validateCompleteReportPins({ ...inventoryResult.provenance.input, datasetPins: [{ ...inventoryResult.provenance.input.datasetPins[0]!, authorizationRevision: 8 }] })).toEqual({ valid: false, reason: 'revision_mismatch' });
}
});
test('after-end pages keep all-matching summaries and terminate the cursor', () => {
expect(afterEndInventoryResult.status).toBe('ready');
if (afterEndInventoryResult.status === 'ready') {
expect(afterEndInventoryResult.pageState).toBe('after_end');
expect(afterEndInventoryResult.rows).toHaveLength(0);
expect(afterEndInventoryResult.summary.matchingRows).toBe(2);
expect(afterEndInventoryResult.nextCursor).toBeNull();
expect(afterEndInventoryCursorState.status).toBe('exhausted');
expect(afterEndInventoryCursorState.position.lastItemId).toBe('90000000000000042');
expect(validateInventoryPageResult(afterEndInventoryResult)).toEqual({ valid: true });
expect(validateInventoryPageResult({
status: 'ready', rows: [], summary: { matchingRows: 2, matchingQuantity: '0', estimatedValue: null, scope: 'all_matching_rows' },
nextCursor: null, pageState: 'initial', provenance: inventoryResult.provenance!,
} as unknown as Parameters<typeof validateInventoryPageResult>[0])).toEqual({ valid: false, reason: 'empty_page_summary' });
expect(validateInventoryPageResult({ ...afterEndInventoryResult, nextCursor: inventoryCursor } as unknown as typeof afterEndInventoryResult)).toEqual({ valid: false, reason: 'terminal_cursor' });
}
});
test('singleton facts preserve true, false and legacy unknown states', () => {
expect(singletonRows.map((row) => row.isSingleton)).toEqual([true, false, null]);
});
test('station evidence preserves verified, system-listed, and absent states', () => {
expect(verifiedNamelessStation.name).toBeNull();
expect(verifiedNamelessStation.evidence).toBe('verified');
expect(verifiedNamelessStation.sourceVerified).toBe(true);
expect(systemListedStationPlaceholder.evidence).toBe('system_listed');
expect(systemListedStationPlaceholder.sourceVerified).toBe(false);
expect(absentLocation.kind).toBe('unknown');
expect(absentLocation.sourceVerified).toBeNull();
expect(absentLocation.parentId).toBeNull();
});
test('ambiguous entity matches remain explicit instead of selecting by name', () => {
expect(ambiguousType.status).toBe('ambiguous');
if (ambiguousType.status === 'ambiguous') expect(ambiguousType.candidates).toHaveLength(2);
});
test('wallet is a separately labelled component and preserves signed cents', () => {
expect(walletResult.status).toBe('ready');
if (walletResult.status === 'ready') {
expect(walletResult.components[0]?.component).toBe('wallet_balance');
expect(walletResult.components[0]?.amount).toBe('-125');
expect(walletResult.components[0]?.credentialVersion).toBe(2);
expect(walletResult.provenance.input.datasetPins[0]?.dataset).toBe('wallet_balance');
}
});
test('capability metadata is allowlisted and wallet consent is opt-in', () => {
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]);
expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE, ESI_ASSETS_SCOPE], [ESI_ASSETS_SCOPE]).preservedScopes).toEqual([ESI_ASSETS_SCOPE]);
expect(validateResourceCapabilityScopes('wallet_balance', consent.resourceRequests[0]!).principal).toEqual(syntheticResource.principal);
expect(validateResourceCapabilityScopes('inventory_query', {
...consent.resourceRequests[0]!,
resource: { ...syntheticResource, requiredScopes: [ESI_WALLET_SCOPE] },
}).missingScopes).toEqual([ESI_WALLET_SCOPE]);
const accepted = {
status: 'accepted',
bindingId: consent.bindingId,
resourceGrants: [{
request: consent.resourceRequests[0]!,
grantedScopes: [ESI_ASSETS_SCOPE],
grantRevision: 7,
credentialVersion: 3,
authorizationEpoch: 1,
}],
} as const;
expect(validateConsentCallback(consent, accepted, 1_700_000_100_000)).toEqual({ valid: true });
expect(validateConsentCallback(consent, { ...accepted, resourceGrants: [{ ...accepted.resourceGrants[0]!, request: { ...accepted.resourceGrants[0]!.request, resource: { ...syntheticResource, principal: { ownerUserId: 'fixture-user-a', id: syntheticResource.principal.id, kind: 'character' } } } }] }, 1_700_000_100_000)).toEqual({ valid: true });
expect(validateConsentCallback(consent, {
...accepted,
resourceGrants: [{ ...accepted.resourceGrants[0]!, request: { ...accepted.resourceGrants[0]!.request, resource: { ...syntheticResource, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' } } } }],
}, 1_700_000_100_000)).toEqual({ valid: false, reason: 'resource_mismatch' });
expect(validateConsentCallback(consent, accepted, consent.expiresAt)).toEqual({ valid: false, reason: 'binding_mismatch' });
expect(validateConsentCallback(consent, { ...accepted, resourceGrants: [{ ...accepted.resourceGrants[0]!, grantRevision: Number.NaN }] }, 1_700_000_100_000)).toEqual({ valid: false, reason: 'revision_mismatch' });
expect(validateCapabilityScopes('wallet_balance', ['unallowlisted.scope']).valid).toBe(false);
expect(gateCapabilityWithAuthorization('wallet_balance', {
userId: 'fixture-user-a', resourceAuthorizations: [], selectedResources: [], datasets: [],
}).status).toBe('requirements_missing');
expect(gateCapabilityWithAuthorization('wallet_balance', {
userId: 'fixture-user-a', resourceAuthorizations: [{
...authorization, principal: { kind: 'corporation', id: '99000000000000001', ownerUserId: 'fixture-user-a' }, grantedScopes: [ESI_WALLET_SCOPE],
}], selectedResources: [{
principal: { kind: 'corporation', id: '99000000000000001', ownerUserId: 'fixture-user-a' },
requiredScopes: [ESI_WALLET_SCOPE], authorizationRevision: 1, authorizationEpoch: 1,
}], datasets: [],
}).status).toBe('requirements_missing');
expect(gateCapabilityWithAuthorization('inventory_query', {
userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [
{ principal: { kind: 'character', id: '90000000000000001', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 },
], datasets: [datasetState, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }],
}).status).toBe('available');
expect(gateCapabilityWithAuthorization('inventory_query', {
userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [syntheticResource], datasets: [
datasetState,
{ ...datasetState, dataset: 'reference_data' },
],
}).status).toBe('requirements_missing');
expect(gateCapabilityWithAuthorization('inventory_query', {
userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [syntheticResource], datasets: [
{ ...datasetState, authorizationEpoch: 2 },
{ ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' },
],
}).status).toBe('requirements_missing');
expect(gateCapabilityWithAuthorization('inventory_query', {
userId: 'fixture-user-a', resourceAuthorizations: [authorization], selectedResources: [
syntheticResource,
{ principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 },
], datasets: [datasetState, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }],
}).status).toBe('requirements_missing');
expect(gateCapabilityWithAuthorization('inventory_query', {
userId: 'fixture-user-a', resourceAuthorizations: [
authorization,
{ ...authorization, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, grantedScopes: [] },
], selectedResources: [
syntheticResource,
{ principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, requiredScopes: [ESI_ASSETS_SCOPE], authorizationRevision: 7, authorizationEpoch: 1 },
], datasets: [datasetState, { ...datasetState, principal: { kind: 'character', id: '90000000000000002', ownerUserId: 'fixture-user-a' }, dataset: 'assets' }, { ...datasetState, principal: { kind: 'system', id: 'reference-public' }, dataset: 'reference_data' }],
}).status).toBe('requirements_missing');
});
test('approved money policy fixes grammar, bounds and signed rounding', () => {
expect(approvedMoneyPolicy.input).toBe('decimal_text');
expect(approvedMoneyPolicy.rounding).toBe('half_away_from_zero');
expect(approvedMoneyPolicy.grammar).toBe('json_number_ascii_decimal');
expect(approvedMoneyPolicy.maxRawInputCharacters).toBe(8192);
expect(approvedMoneyPolicy.maxCoefficientDigits).toBe(4096);
expect(approvedMoneyPolicy.exponentRange).toEqual([-1000, 1000]);
expect(approvedMoneyPolicy.centsShiftRange).toEqual([-1000, 1000]);
expect(approvedMoneyPolicy.canonicalizeNegativeZero).toBe(true);
expect(authorization.staleAuthority.maximumWindowMs).toBeNull();
});
});
+280
View File
@@ -5,6 +5,7 @@ import schema from "../src/convex/schema";
import type { Asset } from "../src/lib/domain/model";
process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret";
process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions";
const modules = {
"src/convex/functions.ts": () => import("../src/convex/functions"),
@@ -36,6 +37,10 @@ async function login(t: ReturnType<typeof convexTest>, input: Partial<any> = {})
name: "Capsuleer",
ownerHash: "owner-a",
scopes: ["esi-assets.read_assets.v1"],
intent: "initial_login",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
encryptedTokens: "encrypted",
tokenExpiresAt: Date.now() + 60_000,
sessionHash: "session-hash",
@@ -47,6 +52,7 @@ async function login(t: ReturnType<typeof convexTest>, input: Partial<any> = {})
describe("Convex persistence", () => {
beforeEach(() => {
process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret";
process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions";
});
test("rejects unauthenticated bridge calls and keeps tenants separate", async () => {
@@ -58,6 +64,10 @@ describe("Convex persistence", () => {
name: "Capsuleer",
ownerHash: "owner-a",
scopes: [],
intent: "initial_login",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
encryptedTokens: "secret",
tokenExpiresAt: Date.now() + 60_000,
sessionHash: "hash",
@@ -87,6 +97,8 @@ describe("Convex persistence", () => {
sessionHash: "linked-session",
ownerHash: "owner-a",
userId: first.userId,
intent: "link_character",
initiatingSessionHash: "first-session",
});
expect(linked.userId).toBe(first.userId);
@@ -99,6 +111,84 @@ describe("Convex persistence", () => {
).rejects.toThrow("User does not exist");
});
test("a verified wallet grant authorizes balance, journal and transaction dataset states", async () => {
const t = convexTest({ schema, modules });
const loggedIn = await login(t, {
characterId: 105,
sessionHash: "wallet-session",
scopes: ["esi-wallet.read_character_wallet.v1"],
capability: "wallet_balance",
requestedScopes: ["esi-wallet.read_character_wallet.v1"],
});
const characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId });
const walletStates = characters[0].datasetStatuses.filter((entry: any) => entry.dataset.startsWith("wallet_"));
expect(walletStates).toHaveLength(3);
expect(walletStates.every((entry: any) => entry.status === "never")).toBe(true);
});
test("linking revalidates the initiating session in the persistence transaction", async () => {
const t = convexTest({ schema, modules });
const first = await login(t, { characterId: 110, sessionHash: "stale-link-session" });
await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "stale-link-session" });
await expect(login(t, {
userId: first.userId,
characterId: 111,
ownerHash: "owner-alt",
sessionHash: "new-link-session",
intent: "link_character",
initiatingSessionHash: "stale-link-session",
})).rejects.toThrow("Initiating session is no longer valid");
});
test("two populated tenants cannot borrow character selectors", async () => {
const t = convexTest({ schema, modules });
const first = await login(t, { characterId: 120, sessionHash: "tenant-a-session", ownerHash: "tenant-a-owner" });
const second = await login(t, { characterId: 121, sessionHash: "tenant-b-session", ownerHash: "tenant-b-owner" });
for (const [account, characterId] of [[first, 120], [second, 121]] as const) {
const generation = await t.mutation(mutation("bridgeBeginSync"), {
...bridge, userId: account.userId, characterId,
});
await t.mutation(mutation("bridgeFinishSync"), {
...bridge, userId: account.userId, characterId, generation, chunkCount: 0,
syncedAt: Date.now(), expiresAt: Date.now() + 60_000,
});
}
await expect(t.query(query("bridgeAssetsPage"), {
...bridge, userId: first.userId, characterId: 121,
paginationOpts: { numItems: 10, cursor: null },
})).resolves.toMatchObject({ page: [], generation: null });
await expect(t.mutation(mutation("bridgeSetPrimary"), {
...bridge, userId: first.userId, characterId: 121, initiatingSessionHash: "tenant-a-session",
})).rejects.toThrow("not eligible");
});
test("asset lease binds refreshed authority revisions, scopes, and credential version", async () => {
const t = convexTest({ schema, modules });
const account = await login(t, { characterId: 122, sessionHash: "authority-session", ownerHash: "authority-owner" });
const committed = await t.mutation(mutation("bridgeSaveTokens"), {
...bridge, userId: account.userId, characterId: 122,
encryptedTokens: "refreshed", tokenExpiresAt: Date.now() + 60_000,
scopes: ["esi-assets.read_assets.v1"], expectedVersion: 1, returnAuthority: true,
});
expect(committed).toMatchObject({ saved: true, credentialVersion: 2, grantRevision: 1, authorizationEpoch: 1 });
await expect(t.mutation(mutation("bridgeBeginSync"), {
...bridge, userId: account.userId, characterId: 122,
expectedScopes: ["esi-assets.read_assets.v1"], expectedGrantRevision: 999,
expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2,
})).rejects.toThrow("authorization changed before lease");
await expect(t.mutation(mutation("bridgeBeginSync"), {
...bridge, userId: account.userId, characterId: 122,
expectedScopes: ["other.scope"], expectedGrantRevision: 1,
expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2,
})).rejects.toThrow("scopes changed before lease");
const generation = await t.mutation(mutation("bridgeBeginSync"), {
...bridge, userId: account.userId, characterId: 122,
expectedScopes: ["esi-assets.read_assets.v1"], expectedGrantRevision: 1,
expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2,
});
expect(generation).toEqual(expect.any(String));
});
test("OAuth state is one-time and token writes use CAS", async () => {
const t = convexTest({ schema, modules });
await expect(
@@ -109,6 +199,10 @@ describe("Convex persistence", () => {
encryptedVerifier: "verifier",
expiresAt: Date.now() - 1,
returnTo: "/assets",
intent: "initial_login",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
}),
).rejects.toThrow("Invalid OAuth state expiry");
await expect(
@@ -119,6 +213,10 @@ describe("Convex persistence", () => {
encryptedVerifier: "verifier",
expiresAt: Date.now() + 660_000, // safely beyond the limit, not a 1ms wall-clock race
returnTo: "/assets",
intent: "initial_login",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
}),
).rejects.toThrow("Invalid OAuth state expiry");
await t.mutation(mutation("bridgeCreateOAuth"), {
@@ -128,6 +226,10 @@ describe("Convex persistence", () => {
encryptedVerifier: "verifier",
expiresAt: Date.now() + 60_000,
returnTo: "/assets",
intent: "initial_login",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
});
const consumed = await t.mutation(mutation("bridgeConsumeOAuth"), {
...bridge,
@@ -277,6 +379,38 @@ describe("Convex persistence", () => {
expect(changed.page).toEqual([]);
});
test("credential rotation fences an in-flight asset publication", async () => {
const t = convexTest({ schema, modules });
const loggedIn = await login(t, { characterId: 150, sessionHash: "credential-fence-session" });
const generation = await t.mutation(mutation("bridgeBeginSync"), {
...bridge, userId: loggedIn.userId, characterId: 150,
});
await expect(t.mutation(mutation("bridgeSaveTokens"), {
...bridge,
userId: loggedIn.userId,
characterId: 150,
encryptedTokens: "rotated",
tokenExpiresAt: Date.now() + 60_000,
scopes: ["esi-assets.read_assets.v1"],
expectedVersion: 1,
})).resolves.toBe(true);
await expect(t.mutation(mutation("bridgeFinishSync"), {
...bridge,
userId: loggedIn.userId,
characterId: 150,
generation,
chunkCount: 0,
syncedAt: Date.now(),
expiresAt: Date.now() + 60_000,
})).rejects.toThrow("authorization is stale");
const beforeFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId });
await expect(t.mutation(mutation("bridgeFailSync"), {
...bridge, userId: loggedIn.userId, characterId: 150, generation, error: "stale worker",
})).rejects.toThrow("failure publication is stale");
const afterFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId });
expect(afterFailure).toEqual(beforeFailure);
});
test("invalid snapshot timestamps never promote", async () => {
const t = convexTest({ schema, modules });
const timestamp = Date.now();
@@ -445,4 +579,150 @@ describe("Convex persistence", () => {
await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "session-hash" });
await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull();
});
test("backend rollout gate blocks status subscriptions and sync mutations", async () => {
const previous = process.env.APP_CAPABILITY_ALLOWLIST;
process.env.APP_CAPABILITY_ALLOWLIST = "";
try {
process.env.APP_AUTH_ISSUER = "https://auth.example.test";
const t = convexTest({ schema, modules });
const loggedIn = await login(t, { sessionHash: "gated-session" });
const browser = t.withIdentity({
subject: loggedIn.userId,
issuer: "https://auth.example.test",
sid: loggedIn.sessionId,
} as any);
await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull();
await expect(t.mutation(mutation("bridgeBeginSync"), {
...bridge,
userId: loggedIn.userId,
characterId: 100,
})).rejects.toThrow("asset_sync");
} finally {
if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST;
else process.env.APP_CAPABILITY_ALLOWLIST = previous;
}
});
test("single-primary linking, consent fencing, cancellation and owner quarantine are explicit", async () => {
const t = convexTest({ schema, modules });
const first = await login(t, { characterId: 300, sessionHash: "primary-session", ownerHash: "owner-primary" });
const linked = await login(t, {
userId: first.userId,
characterId: 301,
sessionHash: "link-session",
initiatingSessionHash: "primary-session",
intent: "link_character",
capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"],
approvedExistingScopes: [],
ownerHash: "owner-alt",
});
let characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId });
expect(characters).toEqual(expect.arrayContaining([
expect.objectContaining({ characterId: 300, loginRole: "primary", authorizationStatus: "active" }),
expect.objectContaining({ characterId: 301, loginRole: "data_only", authorizationStatus: "active" }),
]));
await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "primary-session" })).resolves.toBeNull();
await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "link-session" })).resolves.toMatchObject({ userId: first.userId });
await expect(login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-out-alt" })).rejects.toThrow("Data-only");
await expect(login(t, {
userId: first.userId,
characterId: 301,
ownerHash: "owner-alt",
sessionHash: "duplicate-link-session",
intent: "link_character",
initiatingSessionHash: "link-session",
})).rejects.toThrow("already linked");
const other = await login(t, { characterId: 302, ownerHash: "owner-other", sessionHash: "other-session" });
await expect(t.mutation(mutation("bridgeSetPrimary"), {
...bridge, userId: first.userId, characterId: 302, initiatingSessionHash: "link-session",
})).rejects.toThrow("not eligible");
expect(other.userId).not.toBe(first.userId);
await expect(t.mutation(mutation("bridgeInvalidateAuthorization"), {
...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session",
})).rejects.toThrow("Switch the primary character");
await t.mutation(mutation("bridgeSetPrimary"), {
...bridge, userId: first.userId, characterId: 301, initiatingSessionHash: "link-session",
});
await t.mutation(mutation("bridgeInvalidateAuthorization"), {
...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session",
});
characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId });
expect(characters).toEqual(expect.arrayContaining([
expect.objectContaining({ characterId: 300, loginRole: "data_only", authorizationStatus: "revoked" }),
expect.objectContaining({ characterId: 301, loginRole: "primary" }),
]));
await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull();
await expect(t.query(query("bridgeAssetsPage"), {
...bridge, userId: first.userId, characterId: 300,
paginationOpts: { numItems: 10, cursor: null },
})).resolves.toMatchObject({ page: [], generation: null });
const switched = await login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-in-primary" });
expect(switched.userId).toBe(first.userId);
const reduced = await login(t, {
userId: first.userId,
characterId: 301,
sessionHash: "reduced-session",
initiatingSessionHash: "signed-in-primary",
intent: "reconnect",
capability: "asset_search",
targetCharacterId: 301,
requestedScopes: ["esi-assets.read_assets.v1"],
scopes: [],
ownerHash: "owner-alt",
});
expect(reduced.userId).toBe(first.userId);
characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId });
expect(characters.find((character: any) => character.characterId === 301)).toMatchObject({
authorizationStatus: "active",
grantRevision: 2,
datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "blocked" })]),
});
await expect(t.query(query("bridgeAssetsPage"), {
...bridge, userId: first.userId, characterId: 301,
paginationOpts: { numItems: 10, cursor: null },
})).resolves.toMatchObject({ page: [], generation: null });
const conflict = await t.mutation(mutation("bridgeCompleteLogin"), {
...bridge, characterId: 300, name: "Borrowed", ownerHash: "different-owner", scopes: [],
encryptedTokens: "borrowed", tokenExpiresAt: Date.now() + 60_000,
sessionHash: "borrowed-session", sessionExpiresAt: Date.now() + 60_000,
intent: "initial_login", capability: "asset_search",
requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [],
});
expect(conflict).toMatchObject({ rejected: "ownership_conflict" });
characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId });
expect(characters.find((character: any) => character.characterId === 300)).toMatchObject({
authorizationStatus: "quarantined",
datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "quarantined" })]),
});
await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull();
await expect(t.query(query("bridgeAssetsPage"), {
...bridge, userId: first.userId, characterId: 300,
paginationOpts: { numItems: 10, cursor: null },
})).resolves.toMatchObject({ page: [], generation: null });
});
test("backend rollout gate fails closed when propagation is missing or malformed", async () => {
const previous = process.env.APP_CAPABILITY_ALLOWLIST;
try {
for (const value of [undefined, "not-a-capability"]) {
if (value === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST;
else process.env.APP_CAPABILITY_ALLOWLIST = value;
process.env.APP_AUTH_ISSUER = "https://auth.example.test";
const t = convexTest({ schema, modules });
const loggedIn = await login(t, { sessionHash: `missing-gate-${value ?? 'unset'}` });
const browser = t.withIdentity({ subject: loggedIn.userId, issuer: "https://auth.example.test", sid: loggedIn.sessionId } as any);
await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull();
await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100 })).rejects.toThrow("asset_sync");
}
} finally {
if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST;
else process.env.APP_CAPABILITY_ALLOWLIST = previous;
}
});
});
+6
View File
@@ -18,6 +18,12 @@ function character(id: number, overrides: Partial<Character> = {}): Character {
userId: 'user-a',
name: `Pilot ${id}`,
scopes: [ESI_ASSETS_SCOPE],
loginRole: 'primary',
authorizationStatus: 'active',
grantRevision: 1,
credentialVersion: 1,
authorizationEpoch: 1,
datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'fixture', syncedAt: 900 }],
assets: { status: 'ready', syncedAt: 900, expiresAt: 2_000 },
...overrides,
};
+304 -15
View File
@@ -1,11 +1,25 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, test } from "bun:test";
import {
EsiClient,
EsiError,
EsiHttpError,
EsiMarketEstimates,
EsiSchemaError,
ESI_ASSET_MAX_PAGES,
EsiRateLimitError,
EsiStabilityError,
ESI_ASSET_MAX_RESPONSE_BYTES,
ESI_ASSET_MAX_ROWS_PER_PAGE,
ESI_ASSET_SNAPSHOT_MAX_ROWS,
ESI_CACHE_MAX_BYTES,
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,
} from "../src/lib/server/esi";
const asset = (itemId = "9007199254740993", quantity = 2) => ({
@@ -22,6 +36,10 @@ function response(body: unknown, headers: Record<string, string> = {}, status =
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status, headers });
}
function authority(userId: string, characterId: number, scopes: readonly string[] = [ESI_ASSETS_SCOPE], grantRevision = 1, authorizationEpoch = 1, credentialVersion = 1) {
return { userId, principal: `${userId}:principal`, characterId, scopes, grantRevision, authorizationEpoch, credentialVersion } as const;
}
function queueFetch(responses: Response[]) {
const calls: { url: string; init: RequestInit }[] = [];
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -46,11 +64,7 @@ describe("central ESI client", () => {
new Response(null, { status: 304, headers: { ETag: '"one"', "X-Pages": "2", "Cache-Control": "max-age=60" } }),
]);
const client = new EsiClient({ userAgent: "eden-test", fetch: queued.fetch, now: () => 1_000 });
const result = await client.getAssets(42, "secret-token", {
userId: "user-a",
characterId: 42,
scopes: ["esi-assets.read_assets.v1"],
});
const result = await client.getAssets(42, "secret-token", authority("user-a", 42));
expect(result.assets.map((item) => item.itemId)).toEqual(["9007199254740993", "2"]);
expect(result.assets[0]?.characterId).toBe(42);
expect(queued.calls).toHaveLength(3);
@@ -73,16 +87,29 @@ describe("central ESI client", () => {
new Response(null, { status: 304, headers: { ETag: '"b"', "X-Pages": "1", "Cache-Control": "max-age=1" } }),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => now });
await client.getAssets(7, "a", { userId: "one", scopes: ["assets"] });
await client.getAssets(7, "a", authority("one", 7));
now = 2_000;
await client.getAssets(7, "a", { userId: "one", scopes: ["assets"] });
await client.getAssets(7, "b", { userId: "two", scopes: ["assets"] });
await client.getAssets(7, "a", authority("one", 7));
await client.getAssets(7, "b", authority("two", 7));
expect(queued.calls).toHaveLength(6);
expect(new Headers(queued.calls[1]?.init.headers).get("if-none-match")).toBe('"a"');
expect(queued.calls[2]?.init.headers).not.toMatchObject({ "If-None-Match": '"a"' });
expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBe('"a"');
expect(new Headers(queued.calls[4]?.init.headers).get("if-none-match")).toBeNull();
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([
@@ -97,12 +124,12 @@ describe("central ESI client", () => {
random: () => 0,
now: () => 0,
});
await client.getAssets(8, "token", "u");
await client.getAssets(8, "token", authority("u", 8));
expect(delays[0]).toBe(2_000);
const denied = queueFetch([response({}, {}, 401)]);
const deniedClient = new EsiClient({ userAgent: "test", fetch: denied.fetch, now: () => 0 });
await expect(deniedClient.getAssets(8, "token", "u")).rejects.toBeInstanceOf(EsiHttpError);
await expect(deniedClient.getAssets(8, "token", authority("u", 8))).rejects.toBeInstanceOf(EsiHttpError);
expect(denied.calls).toHaveLength(1);
});
@@ -113,16 +140,278 @@ describe("central ESI client", () => {
response([asset("3")], { "X-Pages": "2", ETag: '"three"' }),
]);
const client = new EsiClient({ userAgent: "test", fetch: changing.fetch, now: () => 0 });
await expect(client.getAssets(1, "token", "u")).rejects.toBeInstanceOf(EsiStabilityError);
await expect(client.getAssets(1, "token", authority("u", 1))).rejects.toBeInstanceOf(EsiStabilityError);
expect(changing.calls).toHaveLength(3);
const invalid = queueFetch([response([{ item_id: 1 }], { "X-Pages": "1" })]);
const invalidClient = new EsiClient({ userAgent: "test", fetch: invalid.fetch, now: () => 0 });
await expect(invalidClient.getAssets(1, "token", "u")).rejects.toThrow("invalid response");
await expect(invalidClient.getAssets(1, "token", authority("u", 1))).rejects.toThrow("invalid response");
});
});
describe("typed ESI helper reads", () => {
test("canonicalizes bounded POST IDs, preserves int64 literals, and isolates authority revisions", async () => {
let now = 0;
const queued = queueFetch([
response(readFileSync("tests/fixtures/esi/asset-names-int64.json", "utf8"), { ETag: '"names"', "Cache-Control": "max-age=0" }),
new Response(null, { status: 304, headers: { ETag: '"names"', "Cache-Control": "max-age=60" } }),
response('[{"item_id":9007199254740993,"name":"Fresh"}]', { ETag: '"fresh"', "Cache-Control": "max-age=60" }),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => now });
const authority = { userId: "user", principal: "user:principal", characterId: 42, scopes: [ESI_ASSETS_SCOPE, "b", "a"], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 };
const first = await client.getAssetNames(42, ["9007199254740993", "2"], "token", authority);
expect(first).toMatchObject({ requestedIds: ["2", "9007199254740993"], missing: ["2"], complete: false });
expect(queued.calls[0]?.url).toBe("https://esi.evetech.net/characters/42/assets/names");
expect(queued.calls[0]?.init.method).toBe("POST");
expect(queued.calls[0]?.init.body).toBe("[2,9007199254740993]");
now = 2_000;
const reused = await client.getAssetNames(42, ["2", "9007199254740993"], "token", authority);
expect(reused.names).toEqual([{ itemId: "9007199254740993", name: "Freighter" }]);
expect(new Headers(queued.calls[1]?.init.headers).get("if-none-match")).toBe('"names"');
await client.getAssetNames(42, ["2", "9007199254740993"], "token", { ...authority, grantRevision: 2 });
expect(queued.calls).toHaveLength(3);
});
test("validates helper schemas, bounds, and preserves explicit missing locations", async () => {
const locations = queueFetch([
response(readFileSync("tests/fixtures/esi/asset-locations-finite.json", "utf8"), { "Cache-Control": "max-age=60" }),
]);
const client = new EsiClient({ userAgent: "test", fetch: locations.fetch, now: () => 10 });
const result = await client.getAssetLocations(7, ["9007199254740993", "8"], "token", authority("u", 7));
expect(result).toMatchObject({ missing: ["8"], complete: false, locations: [{ itemId: "9007199254740993", position: { x: 1, y: -2, z: 0 } }] });
expect(locations.calls[0]?.url).toBe("https://esi.evetech.net/characters/7/assets/locations");
expect(locations.calls[0]?.init.method).toBe("POST");
const invalid = queueFetch([response('[{"item_id":8,"position":{"x":1e10000,"y":0,"z":0}}]')]);
const invalidClient = new EsiClient({ userAgent: "test", fetch: invalid.fetch, now: () => 0 });
await expect(invalidClient.getAssetLocations(7, ["8"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(invalidClient.getAssetLocations(7, [], "token", authority("u", 7))).rejects.toMatchObject({ code: "invalid_asset_ids" });
await expect(invalidClient.getAssetLocations(7, ["1", "1"], "token", authority("u", 7))).rejects.toMatchObject({ code: "invalid_asset_ids" });
await expect(invalidClient.getAssetLocations(7, ["9223372036854775808"], "token", authority("u", 7))).rejects.toMatchObject({ code: "invalid_asset_ids" });
});
test("retries safe POST reads, honors no-store and exposes wallet exact cents", async () => {
const delays: number[] = [];
let now = 0;
const queued = queueFetch([
response({}, {}, 503),
response('[{"item_id":1,"name":"Box"}]', { ETag: '"box"', "Cache-Control": "no-store" }),
response('[{"item_id":1,"name":"Box"}]', { ETag: '"box2"', "Cache-Control": "no-store" }),
response(readFileSync("tests/fixtures/esi/wallet-balance-signed.json", "utf8"), { ETag: '"wallet"', "Cache-Control": "max-age=1" }),
new Response(null, { status: 304, headers: { ETag: '"wallet"', "Cache-Control": "max-age=60" } }),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => now, sleep: async (ms) => { delays.push(ms); }, random: () => 0 });
await client.getAssetNames(7, ["1"], "token", authority("u", 7));
await client.getAssetNames(7, ["1"], "token", authority("u", 7));
expect(delays).toContain(250);
expect(queued.calls[0]?.init.method).toBe("POST");
expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBeNull();
const wallet = await client.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]));
expect(wallet.amount).toBe("-900719925474099301");
expect(queued.calls[3]?.url).toBe("https://esi.evetech.net/characters/7/wallet");
expect(queued.calls[3]?.init.method).toBe("GET");
now = 500;
await client.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]));
expect(queued.calls).toHaveLength(4);
now = 2_000;
const refreshed = await client.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]));
expect(refreshed.amount).toBe(wallet.amount);
expect(new Headers(queued.calls[4]?.init.headers).get("if-none-match")).toBe('"wallet"');
});
test("uses the asset endpoint page cap and retains singleton source facts", async () => {
const queued = queueFetch([response("[]", { "X-Pages": String(ESI_ASSET_MAX_PAGES + 1) })]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
await expect(client.getAssets(7, "token", authority("u", 7))).rejects.toMatchObject({ code: "pagination_error" });
const singletonQueue = queueFetch([
response('[{"item_id":1,"type_id":34,"quantity":10,"location_id":60003760,"location_type":"station","location_flag":"Hangar","is_singleton":true}]', { "X-Pages": "1" }),
new Response(null, { status: 304, headers: { "X-Pages": "1" } }),
]);
const singletonClient = new EsiClient({ userAgent: "test", fetch: singletonQueue.fetch, now: () => 0 });
const snapshot = await singletonClient.getAssets(7, "token", authority("u", 7));
expect(snapshot.assets[0]?.isSingleton).toBe(true);
});
});
describe("T06 transport bounds, authority, and metadata", () => {
test("requires complete scoped authority and asserts pinned route metadata", async () => {
const metadata = JSON.parse(readFileSync("tests/fixtures/esi/transport-metadata.json", "utf8")) as any;
expect(metadata.compatibilityDate).toBe(ESI_COMPATIBILITY_DATE);
expect(metadata.evidence.assetHelpers).toEqual({ source: "docs/research/eve-openapi-evidence.json", verification: "pinned-authoritative" });
expect(metadata.evidence.walletBalance).toMatchObject({
source: "https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18",
verification: "current-retrieval-with-pinned-compatibility-date-not-historical-capture",
retrievedAt: "2026-09-19T19:30:12Z",
responseDate: "2026-08-18",
openapiVersion: "2026-08-18",
sha256: "9a9a0f4af77216300e960cfd2576c9baac085a8170022e40c928c28622d80fc6",
operationId: "GetCharactersCharacterIdWallet",
route: "/characters/{character_id}/wallet",
method: "GET",
requiredScope: ESI_WALLET_SCOPE,
pagination: "none",
response: { status: 200, mediaType: "application/json", shape: "scalar number/double" },
cache: { ttlSeconds: 120, policy: "ttl-based", validators: ["Cache-Control", "ETag", "Last-Modified"] },
rateLimit: { group: "char-wallet", tokens: 150, window: "15m" },
officialDocs: {
versioning: "https://developers.eveonline.com/docs/services/esi/overview/",
caching: "https://developers.eveonline.com/docs/services/esi/overview/#caching",
rateLimiting: "https://developers.eveonline.com/docs/services/esi/rate-limiting/",
},
});
for (const [operation, expected] of Object.entries(metadata.operations) as [keyof typeof ESI_READ_OPERATIONS, any][]) {
const descriptor = ESI_READ_OPERATIONS[operation];
expect({ route: `/${descriptor.path}`, method: descriptor.method, requiredScope: descriptor.requiredScope }).toEqual(expected);
}
const client = new EsiClient({ userAgent: "test", fetch: queueFetch([response("[{\"item_id\":1,\"name\":\"x\"}]")]).fetch, now: () => 0 });
await expect(client.getAssetNames(7, ["1"], "token", "legacy" as never)).rejects.toMatchObject({ code: "authorization_context" });
await expect(client.getAssetNames(7, ["1"], "token", { ...authority("u", 7), grantRevision: undefined } as never)).rejects.toMatchObject({ code: "authorization_context" });
await expect(client.getAssetNames(7, ["1"], "token", authority("u", 7, ["other.scope"]))).rejects.toMatchObject({ code: "authorization_context" });
await expect(client.getWalletBalance(7, "token", authority("u", 7))).rejects.toMatchObject({ code: "authorization_context" });
});
test("isolates every private cache authority dimension and uses correct request metadata", async () => {
const queued = queueFetch(Array.from({ length: 8 }, (_, index) => response(`[{\"item_id\":1,\"name\":\"${index}\"}]`, { "Cache-Control": "max-age=60" })));
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
const contexts = [
authority("u", 7),
{ ...authority("u", 7), principal: "u:other-principal" },
{ ...authority("u", 7), credentialVersion: 2 },
authority("other", 7),
authority("u", 8),
authority("u", 7, [ESI_ASSETS_SCOPE, "extra"]),
authority("u", 7, [ESI_ASSETS_SCOPE], 2, 1),
authority("u", 7, [ESI_ASSETS_SCOPE], 1, 2),
];
for (const context of contexts) await client.getAssetNames(context.characterId, ["1"], "token", context as any);
expect(queued.calls).toHaveLength(8);
expect(queued.calls.every((call) => call.init.method === "POST")).toBe(true);
expect(queued.calls.every((call) => new Headers(call.init.headers).get("content-type") === "application/json")).toBe(true);
expect(new Headers(queued.calls[0]?.init.headers).get("authorization")).toBe("Bearer token");
expect(new Headers(queued.calls[1]?.init.headers).get("if-none-match")).toBeNull();
});
test("counts received rows before duplicate handling", async () => {
const duplicatePage = JSON.stringify(Array.from({ length: 5_000 }, () => asset("1")));
const responses = Array.from({ length: 11 }, () => response(duplicatePage, { "X-Pages": "11", "Cache-Control": "max-age=60" }));
const queued = queueFetch(responses);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
await expect(client.getAssets(7, "token", authority("duplicate-heavy", 7))).rejects.toMatchObject({ code: "response_too_large" });
expect(queued.calls).toHaveLength(11);
});
test("shares one deadline across all asset pages and final revalidation", async () => {
let now = 0;
const calls: (RequestInfo | URL)[] = [];
const client = new EsiClient({
userAgent: "test",
now: () => now,
fetch: (async (input) => {
calls.push(input);
now = 16_000;
return response([asset("1")], { "X-Pages": "2" });
}) as typeof globalThis.fetch,
});
await expect(client.getAssets(7, "token", authority("u", 7))).rejects.toMatchObject({ code: "deadline" });
expect(calls).toHaveLength(1);
});
test("rejects oversized content/streams and oversized asset pages before caching", async () => {
const contentLengthQueue = queueFetch([response("[]", { "Content-Length": String(ESI_ASSET_NAME_MAX_RESPONSE_BYTES + 1) })]);
const contentLengthClient = new EsiClient({ userAgent: "test", fetch: contentLengthQueue.fetch, now: () => 0 });
await expect(contentLengthClient.getAssetNames(7, ["1"], "token", authority("u", 7))).rejects.toMatchObject({ code: "response_too_large" });
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("x".repeat(ESI_WALLET_MAX_RESPONSE_BYTES + 1)));
controller.close();
},
});
const streamQueue = queueFetch([new Response(stream)]);
const streamClient = new EsiClient({ userAgent: "test", fetch: streamQueue.fetch, now: () => 0 });
await expect(streamClient.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]))).rejects.toMatchObject({ code: "response_too_large" });
const oversizedPage = JSON.stringify(Array.from({ length: ESI_ASSET_MAX_ROWS_PER_PAGE + 1 }, () => ({})));
const pageQueue = queueFetch([response(oversizedPage, { "X-Pages": "1" })]);
const pageClient = new EsiClient({ userAgent: "test", fetch: pageQueue.fetch, now: () => 0 });
await expect(pageClient.getAssets(7, "token", authority("u", 7))).rejects.toMatchObject({ code: "response_too_large" });
expect(pageClient.getCacheStats().cacheEntries).toBe(0);
});
test("rejects malformed helper arrays, duplicate/unexpected IDs, malformed wallet, and negative zero", async () => {
const queued = queueFetch([
response("[{\"item_id\":1,\"name\":\"a\"},{\"item_id\":1,\"name\":\"b\"}]"),
response("[{\"item_id\":2,\"name\":\"a\"}]"),
response("{}"),
response("not-a-number"),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
await expect(client.getAssetNames(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetNames(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetLocations(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetNames(7, ["-0"], "token", authority("u", 7))).rejects.toMatchObject({ code: "invalid_asset_ids" });
});
test("records cooldowns on successful responses, exposes typed queries, and bounds maps", async () => {
let now = 0;
const cooldownAuth = authority("u", 7, [ESI_WALLET_SCOPE]);
const cooldownQueue = queueFetch([
response("1", { "Cache-Control": "max-age=0", "X-Ratelimit-Remaining": "0", "X-Ratelimit-Group": "wallet", "Retry-After": "10" }),
response("1", { "Cache-Control": "max-age=60" }),
]);
const sleeps: number[] = [];
const client = new EsiClient({ userAgent: "test", fetch: cooldownQueue.fetch, now: () => now, sleep: async (ms) => { sleeps.push(ms); } });
await client.getWalletBalance(7, "token", cooldownAuth);
expect(client.getCooldown(cooldownAuth, 7)).toMatchObject({ delayMs: 10_000, group: "wallet", kind: "new" });
now = 1_000;
await client.getWalletBalance(7, "token", cooldownAuth);
expect(sleeps).toContain(9_000);
const thrownQueue = queueFetch([response("rate", { "Retry-After": "20", "X-Ratelimit-Remaining": "0" }, 429)]);
const thrown = new EsiClient({ userAgent: "test", fetch: thrownQueue.fetch, now: () => 0, sleep: async () => {} });
await expect(thrown.getWalletBalance(7, "token", cooldownAuth)).rejects.toMatchObject({ code: "rate_limited", cooldown: { delayMs: 20_000 } });
const boundedQueue = queueFetch(Array.from({ length: 520 }, (_, index) => response(`[{\"item_id\":${index + 1},\"name\":\"x\"}]`, { "Cache-Control": "max-age=60" })));
const bounded = new EsiClient({ userAgent: "test", fetch: boundedQueue.fetch, now: () => 0 });
for (let index = 0; index < 520; index += 1) await bounded.getAssetNames(7, [String(index + 1)], "token", authority("u", 7));
expect(bounded.getCacheStats().cacheEntries).toBeLessThanOrEqual(512);
expect(bounded.getCacheStats(7 * 60 * 60 * 1000).cacheEntries).toBe(0);
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 < 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(80);
const snapshotRows = Array.from({ length: 1_000 }, (_, index) => asset(String(index + 1)));
const snapshotBody = JSON.stringify(snapshotRows);
const snapshotResponses: Response[] = [];
for (let index = 0; index < 51; index += 1) {
snapshotResponses.push(response(snapshotBody, { "X-Pages": "1", "Cache-Control": "max-age=60" }));
snapshotResponses.push(new Response(null, { status: 304, headers: { "X-Pages": "1", "Cache-Control": "max-age=60" } }));
}
const snapshots = new EsiClient({ userAgent: "test", fetch: queueFetch(snapshotResponses).fetch, now: () => 0 });
for (let index = 0; index < 51; index += 1) await snapshots.getAssets(7, "token", authority(`snapshot-${index}`, 7));
const snapshotStats = snapshots.getCacheStats();
expect(snapshotStats.assetSnapshotRows).toBeLessThanOrEqual(ESI_ASSET_SNAPSHOT_MAX_ROWS);
expect(snapshotStats.assetSnapshots).toBeLessThan(51);
});
});
describe("ESI market estimates", () => {
test("keeps the inherited non-negative market-price policy", async () => {
const queued = queueFetch([
response([{ type_id: 34, average_price: -1 }], { ETag: '"negative"', "Cache-Control": "max-age=60" }),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1234 });
await expect(new EsiMarketEstimates(client).estimates([34])).rejects.toMatchObject({ code: "invalid_price" });
});
test("uses average prices only and returns cents with retrieval provenance", async () => {
const queued = queueFetch([
response([
@@ -200,8 +489,8 @@ describe("ESI cache and limiter edge cases", () => {
response(body, { "Cache-Control": "no-store", ETag: '"nope2"', "X-Pages": "1" }),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
await client.getAssets(10, "token", "u");
await client.getAssets(10, "token", "u");
await client.getAssets(10, "token", authority("u", 10));
await client.getAssets(10, "token", authority("u", 10));
expect(queued.calls).toHaveLength(4);
expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBeNull();
});
+1
View File
@@ -0,0 +1 @@
[{"item_id":9007199254740993,"position":{"x":1,"y":-2,"z":0}}]
+1
View File
@@ -0,0 +1 @@
[{"item_id":9007199254740993,"name":"Freighter"}]
+45
View File
@@ -0,0 +1,45 @@
{
"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",
"retrievedAt": "2026-09-19T19:30:12Z",
"responseDate": "2026-08-18",
"openapiVersion": "2026-08-18",
"sha256": "9a9a0f4af77216300e960cfd2576c9baac085a8170022e40c928c28622d80fc6",
"operationId": "GetCharactersCharacterIdWallet",
"route": "/characters/{character_id}/wallet",
"method": "GET",
"requiredScope": "esi-wallet.read_character_wallet.v1",
"characterId": { "type": "int64", "required": true },
"pagination": "none",
"response": { "status": 200, "mediaType": "application/json", "shape": "scalar number/double" },
"cache": { "ttlSeconds": 120, "policy": "ttl-based", "validators": ["Cache-Control", "ETag", "Last-Modified"] },
"rateLimit": { "group": "char-wallet", "tokens": 150, "window": "15m" },
"officialDocs": {
"versioning": "https://developers.eveonline.com/docs/services/esi/overview/",
"caching": "https://developers.eveonline.com/docs/services/esi/overview/#caching",
"rateLimiting": "https://developers.eveonline.com/docs/services/esi/rate-limiting/"
}
}
},
"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" }
}
}
+1
View File
@@ -0,0 +1 @@
-9007199254740993.01
+5
View File
@@ -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"}
]
+5
View File
@@ -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}
]
+7
View File
@@ -0,0 +1,7 @@
[
{"itemId":"9007199254740993","characterId":42,"typeId":100,"quantity":2,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":true},
{"itemId":"9007199254740995","characterId":42,"typeId":101,"quantity":3,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":false},
{"itemId":"9007199254740997","characterId":42,"typeId":102,"quantity":1,"locationId":"8000000000000001","locationType":"other","locationFlag":"Hangar","isBlueprintCopy":true},
{"itemId":"9007199254740999","characterId":43,"typeId":100,"quantity":4,"locationId":"60003760","locationType":"station","locationFlag":"Hangar"},
{"itemId":"9007199254741001","characterId":43,"typeId":103,"quantity":5,"locationId":"9999999999999999","locationType":"other","locationFlag":"Hangar"}
]
+41
View File
@@ -0,0 +1,41 @@
import { ESI_WALLET_SCOPE } from '../../../src/lib/server/esi';
import type { AuthorizationKey } from '../../../src/lib/server/esi';
import type { ResourcePrincipal, SelectedResource } from '../../../src/lib/domain/model';
import type {
WalletAuthorizedResource,
WalletAuthorityFence,
WalletBalanceSnapshot,
WalletCredential,
} from '../../../src/lib/server/wallet/balance-repository';
export function resource(userId: string, characterId: number, overrides: Partial<WalletAuthorizedResource> = {}): WalletAuthorizedResource {
const principal: Extract<ResourcePrincipal, { kind: 'character' }> = { kind: 'character', id: String(characterId), ownerUserId: userId };
const selected: SelectedResource = { principal, requiredScopes: [ESI_WALLET_SCOPE], authorizationRevision: overrides.grantRevision ?? 1, authorizationEpoch: overrides.authorizationEpoch ?? 1 };
return {
userId,
characterId,
principal: overrides.principal ?? principal,
resource: overrides.resource ?? selected,
authorizationStatus: 'active',
consent: 'granted',
grantedScopes: [ESI_WALLET_SCOPE],
grantRevision: 1,
authorizationEpoch: 1,
credentialVersion: 1,
...overrides,
};
}
export function fence(value: WalletAuthorizedResource): WalletAuthorityFence {
return { userId: value.userId, characterId: value.characterId, principal: { ...value.principal }, grantRevision: value.grantRevision, authorizationEpoch: value.authorizationEpoch, credentialVersion: value.credentialVersion!, scopes: [...value.grantedScopes] };
}
export function snapshot(value: WalletAuthorizedResource, amount: string, fetchedAt = 100, expiresAt = 1_000, generation = `wallet-${value.characterId}-${fetchedAt}`): WalletBalanceSnapshot {
return { userId: value.userId, characterId: value.characterId, principal: { ...value.principal }, amount, fetchedAt, expiresAt, upstreamEffectiveAt: null, coverage: { kind: 'snapshot', generation, complete: true }, generation, authorizationRevision: value.grantRevision, authorizationEpoch: value.authorizationEpoch, credentialVersion: value.credentialVersion!, };
}
export function credential(value: WalletAuthorizedResource, token = `token-${value.userId}-${value.characterId}`): { fence: WalletAuthorityFence; credential: WalletCredential } {
const current = fence(value);
const authorizationKey: AuthorizationKey = { userId: value.userId, principal: `character:${value.principal.id}`, characterId: value.characterId, scopes: [...value.grantedScopes], grantRevision: value.grantRevision, authorizationEpoch: value.authorizationEpoch, credentialVersion: value.credentialVersion! };
return { fence: current, credential: { accessToken: token, authorizationKey } };
}
+19 -1
View File
@@ -20,6 +20,10 @@ describe('Infisical boundary', () => {
test('accepts Bun-forwarded commands and preserves child --help arguments', () => {
expect(parseArguments(['run', 'docker', 'compose', 'config', '--quiet']).command).toEqual(['docker', 'compose', 'config', '--quiet']);
expect(parseArguments(['run', '--deployment', '--', 'bun', 'tool.ts', '--help'])).toMatchObject({ deployment: true, command: ['bun', 'tool.ts', '--help'] });
expect(parseArguments(['run', '--worker', '--', 'bun', 'workers/sync.ts'])).toMatchObject({ worker: true, command: ['bun', 'workers/sync.ts'] });
expect(() => parseArguments(['run', '--configure', '--', 'bun', 'scripts/configure-convex.ts'])).toThrow('--configure requires --deployment');
expect(parseArguments(['run', '--deployment', '--configure', '--', 'bun', 'scripts/configure-convex.ts'])).toMatchObject({ deployment: true, configure: true, command: ['bun', 'scripts/configure-convex.ts'] });
expect(() => parseArguments(['run', '--worker', '--deployment', '--', 'bun', 'worker.ts'])).toThrow();
expect(parseArguments(['status']).action).toBe('status');
expect(() => parseArguments(['run', '--unknown'])).toThrow();
expect(() => parseArguments(['status', '--', 'printenv'])).toThrow();
@@ -87,10 +91,11 @@ describe('Infisical boundary', () => {
test('child processes receive only managed app keys, without bootstrap or ambient admin credentials', () => {
const base = { PATH: '/usr/bin', INFISICAL_TOKEN: 'token', [ADMIN_KEY]: 'ambient-admin', ...credentials, TYPESAFE_API_KEY: 'stale-key' };
const values = { EVE_CLIENT_ID: 'client', EVE_CLIENT_SECRET: 'secret', [ADMIN_KEY]: 'vault-admin', NODE_OPTIONS: 'not-injected' };
const values = { EVE_CLIENT_ID: 'client', EVE_CLIENT_SECRET: 'secret', CONVEX_BRIDGE_SECRET: 'bridge', CONVEX_SYNC_WORKER_SECRET: 'worker', APP_AUTH_PRIVATE_KEY: 'private', APP_AUTH_JWKS: 'jwks', [ADMIN_KEY]: 'vault-admin', NODE_OPTIONS: 'not-injected' };
const config = { APP_ORIGIN: 'https://dev.evebot.app' };
const env = applicationEnvironment(base, values, config);
expect(env.EVE_CLIENT_SECRET).toBe('secret');
expect(env.CONVEX_SYNC_WORKER_SECRET).toBeUndefined();
expect(env.PATH).toBe('/usr/bin');
expect(env.APP_ORIGIN).toBe(config.APP_ORIGIN);
expect(env.TYPESAFE_API_KEY).toBeUndefined();
@@ -98,6 +103,19 @@ describe('Infisical boundary', () => {
expect(env.NODE_OPTIONS).toBeUndefined();
expect(Object.keys(env).some(key => key.startsWith('INFISICAL_'))).toBe(false);
expect(applicationEnvironment(base, values, config, true)[ADMIN_KEY]).toBe('vault-admin');
const workerEnv = applicationEnvironment(base, values, config, false, 'worker');
expect(workerEnv.CONVEX_SYNC_WORKER_SECRET).toBe('worker');
expect(workerEnv.CONVEX_BRIDGE_SECRET).toBeUndefined();
expect(workerEnv.APP_AUTH_PRIVATE_KEY).toBeUndefined();
expect(workerEnv.APP_AUTH_JWKS).toBeUndefined();
const configureEnv = applicationEnvironment(base, values, { ...config, APP_AUTH_ISSUER: 'https://issuer.example', APP_CAPABILITY_ALLOWLIST: 'asset_sync' }, true, 'configure');
expect(configureEnv.CONVEX_BRIDGE_SECRET).toBe('bridge');
expect(configureEnv.CONVEX_SYNC_WORKER_SECRET).toBe('worker');
expect(configureEnv.APP_AUTH_JWKS).toBe('jwks');
expect(configureEnv[ADMIN_KEY]).toBe('vault-admin');
expect(configureEnv.APP_AUTH_ISSUER).toBe('https://issuer.example');
expect(configureEnv.APP_CAPABILITY_ALLOWLIST).toBe('asset_sync');
for (const excluded of ['TOKEN_ENCRYPTION_KEY', 'APP_AUTH_PRIVATE_KEY', 'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TYPESAFE_API_KEY']) expect(configureEnv[excluded]).toBeUndefined();
expect(() => applicationEnvironment(base, values, { INFISICAL_TOKEN: 'do-not-forward' })).toThrow('Unexpected public');
expect(() => applicationEnvironment(base, values, { NODE_OPTIONS: 'do-not-execute' })).toThrow('Unexpected public');
});

Some files were not shown because too many files have changed in this diff Show More