From 2675ea81147e4733de41809ab44768022e4d189b Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 19 Sep 2026 15:43:22 -0400 Subject: [PATCH] feat: add typed ESI transport extensions --- docs/esi-operations.md | 95 ++- docs/parallel-worktrees.md | 12 + src/convex/functions.ts | 32 +- src/convex/schema.ts | 2 + src/lib/domain/assets.ts | 1 + src/lib/server/application.ts | 59 +- src/lib/server/esi/index.ts | 731 ++++++++++++++++-- src/lib/server/store.ts | 37 +- tests/convex.test.ts | 27 + tests/esi.test.ts | 297 ++++++- .../fixtures/esi/asset-locations-finite.json | 1 + tests/fixtures/esi/asset-names-int64.json | 1 + tests/fixtures/esi/transport-metadata.json | 33 + tests/fixtures/esi/wallet-balance-signed.json | 1 + 14 files changed, 1205 insertions(+), 124 deletions(-) create mode 100644 tests/fixtures/esi/asset-locations-finite.json create mode 100644 tests/fixtures/esi/asset-names-int64.json create mode 100644 tests/fixtures/esi/transport-metadata.json create mode 100644 tests/fixtures/esi/wallet-balance-signed.json diff --git a/docs/esi-operations.md b/docs/esi-operations.md index dbb9832..89e8653 100644 --- a/docs/esi-operations.md +++ b/docs/esi-operations.md @@ -1,9 +1,11 @@ # 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). Callers cannot provide an arbitrary ESI +URL, host, method, or POST body. ## Asset synchronization @@ -12,37 +14,94 @@ 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. + +## 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 diff --git a/docs/parallel-worktrees.md b/docs/parallel-worktrees.md index b05228b..ea7bd0c 100644 --- a/docs/parallel-worktrees.md +++ b/docs/parallel-worktrees.md @@ -8,6 +8,18 @@ **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 evidence:** The centralized ESI client now 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. Asset snapshots report bounded non-atomic coverage; page-one +revalidation is not presented as a transaction. T07 durable scheduling/worker +orchestration, T08 location/service orchestration, and T13 wallet persistence/UI/ +service orchestration remain unimplemented; production-readiness gates remain +deferred. 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. diff --git a/src/convex/functions.ts b/src/convex/functions.ts index 48de8a2..37599f6 100644 --- a/src/convex/functions.ts +++ b/src/convex/functions.ts @@ -540,6 +540,7 @@ export const bridgeSaveTokens = mutationGeneric({ tokenExpiresAt: v.number(), scopes: v.array(v.string()), expectedVersion: v.number(), + returnAuthority: v.optional(v.boolean()), }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); @@ -569,6 +570,15 @@ export const bridgeSaveTokens = mutationGeneric({ datasetStatuses: updated.statuses, ...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}), }); + if (args.returnAuthority) { + return { + saved: true, + credentialVersion: nextVersion, + grantRevision: nextRevision, + authorizationEpoch: character.authorizationEpoch, + scopes: args.scopes, + }; + } return true; }, }); @@ -713,7 +723,14 @@ export const bridgeAssetsPage = queryGeneric({ }); export const bridgeBeginSync = mutationGeneric({ - args: { ...bridgeArgs, ...characterArgs }, + args: { + ...bridgeArgs, + ...characterArgs, + expectedScopes: v.optional(v.array(v.string())), + expectedGrantRevision: v.optional(v.number()), + expectedAuthorizationEpoch: v.optional(v.number()), + expectedCredentialVersion: v.optional(v.number()), + }, handler: async (ctx, args) => { assertBridge(args.bridgeSecret); assertDeploymentCapability('asset_sync'); @@ -722,6 +739,19 @@ export const bridgeBeginSync = mutationGeneric({ if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) { throw new Error("Asset authorization is not active"); } + if (args.expectedGrantRevision !== undefined && args.expectedGrantRevision !== character.grantRevision) { + throw new Error("Synchronization authorization changed before lease"); + } + if (args.expectedAuthorizationEpoch !== undefined && args.expectedAuthorizationEpoch !== character.authorizationEpoch) { + throw new Error("Synchronization authorization changed before lease"); + } + if (args.expectedCredentialVersion !== undefined && args.expectedCredentialVersion !== character.credentialVersion) { + throw new Error("Synchronization credential changed before lease"); + } + if (args.expectedScopes !== undefined + && JSON.stringify([...args.expectedScopes].sort()) !== JSON.stringify([...character.scopes].sort())) { + throw new Error("Synchronization scopes changed before lease"); + } const timestamp = now(); if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) { throw new Error("Character synchronization is already leased"); diff --git a/src/convex/schema.ts b/src/convex/schema.ts index f115b5c..908f35f 100644 --- a/src/convex/schema.ts +++ b/src/convex/schema.ts @@ -48,6 +48,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()), }); diff --git a/src/lib/domain/assets.ts b/src/lib/domain/assets.ts index 50718f0..2e31b30 100644 --- a/src/lib/domain/assets.ts +++ b/src/lib/domain/assets.ts @@ -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); diff --git a/src/lib/server/application.ts b/src/lib/server/application.ts index 55fdebd..8565240 100644 --- a/src/lib/server/application.ts +++ b/src/lib/server/application.ts @@ -91,8 +91,13 @@ export async function assetReport( finally { reference?.close(); } } -const refreshes = new Map>(); -async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise { +type CommittedGrant = VerifiedGrant & { + credentialVersion: number; + grantRevision: number; + authorizationEpoch: number; +}; +const refreshes = new Map>(); +async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise { const key = `${userId}:${characterId}`; const existing = refreshes.get(key); if (existing) return existing; const promise = (async () => { @@ -101,7 +106,16 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn if (!record) throw new Error('Character authorization is missing'); const previous = await cipher.open(record.encryptedTokens, `character:${characterId}`); if (previous.characterId !== characterId) throw new Error('Character authorization mismatch'); - if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous; + if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) { + const current = (await store.characters(userId)).find((entry) => entry.id === characterId); + if (!current) throw new Error('Character authorization is missing'); + return { + ...previous, + credentialVersion: current.credentialVersion, + grantRevision: current.grantRevision, + authorizationEpoch: current.authorizationEpoch, + }; + } const grant = await sso.refresh(previous.refreshToken); if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) { await store.quarantineAuthorization(userId, characterId).catch(() => {}); @@ -109,7 +123,14 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn } 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; + return { + ...grant, + credentialVersion: saved.credentialVersion, + grantRevision: saved.grantRevision, + authorizationEpoch: saved.authorizationEpoch, + scopes: saved.scopes, + }; + })(); refreshes.set(key, promise); try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); } @@ -132,8 +153,34 @@ export async function synchronize(userId: string) { // that actually authorizes the ESI request and publication. const grant = await characterGrant(userId, character.id, true); if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing'); - generation = await store.beginSync(userId, character.id); - const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`); + // Refresh/CAS may have advanced authority while the initial character + // list was in memory. Reload before leasing and bind beginSync to the + // exact revisions/scopes that authorize the transport request. + const current = (await store.characters(userId)).find((entry) => entry.id === character.id); + if (!current || current.authorizationStatus !== 'active' + || !current.scopes.includes('esi-assets.read_assets.v1') + || current.credentialVersion !== grant.credentialVersion + || current.grantRevision !== grant.grantRevision + || current.authorizationEpoch !== grant.authorizationEpoch + || JSON.stringify([...current.scopes].sort()) !== JSON.stringify([...grant.scopes].sort())) { + throw new Error('Asset authorization changed after credential commit'); + } + const authority = { + userId, + principal: grant.ownerHash, + characterId: current.id, + scopes: current.scopes, + grantRevision: grant.grantRevision, + authorizationEpoch: grant.authorizationEpoch, + credentialVersion: grant.credentialVersion, + } as const; + generation = await store.beginSync(userId, current.id, { + scopes: authority.scopes, + grantRevision: authority.grantRevision, + authorizationEpoch: authority.authorizationEpoch, + credentialVersion: authority.credentialVersion, + }); + const snapshot = await esi.getAssets(current.id, grant.accessToken, authority); 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); diff --git a/src/lib/server/esi/index.ts b/src/lib/server/esi/index.ts index d8825c9..957707e 100644 --- a/src/lib/server/esi/index.ts +++ b/src/lib/server/esi/index.ts @@ -9,24 +9,61 @@ import { decimalIskToCents } from "../../domain/money"; /** The reviewed compatibility date captured in docs/research/eve.md. */ export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const; export const ESI_BASE_URL = "https://esi.evetech.net" as const; +export const ESI_ASSETS_SCOPE = "esi-assets.read_assets.v1" as const; +export const ESI_WALLET_SCOPE = "esi-wallet.read_character_wallet.v1" as const; +/** Endpoint-specific bounds are deliberately well below the old unbounded page cap. */ +export const ESI_ASSET_MAX_PAGES = 1_000; +export const ESI_ASSET_MAX_ROWS = 50_000; +export const ESI_ASSET_MAX_ROWS_PER_PAGE = 5_000; +export const ESI_ASSET_HELPER_MAX_IDS = 1_000; +export const ESI_ASSET_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const ESI_ASSET_NAME_MAX_RESPONSE_BYTES = 1 * 1024 * 1024; +export const ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES = 512 * 1024; +export const ESI_WALLET_MAX_RESPONSE_BYTES = 64 * 1024; +export const ESI_MARKET_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +export const ESI_MARKET_MAX_ROWS = 20_000; + +/** Fixed transport operations; there is deliberately no caller-supplied URL/method. */ +export const ESI_READ_OPERATIONS = { + characterAssets: { method: "GET", path: "characters/{character_id}/assets", pagination: "x-pages", requiredScope: ESI_ASSETS_SCOPE, maxPages: ESI_ASSET_MAX_PAGES, maxRows: ESI_ASSET_MAX_ROWS_PER_PAGE, maxResponseBytes: ESI_ASSET_MAX_RESPONSE_BYTES }, + characterAssetNames: { method: "POST", path: "characters/{character_id}/assets/names", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_NAME_MAX_RESPONSE_BYTES }, + characterAssetLocations: { method: "POST", path: "characters/{character_id}/assets/locations", pagination: "none", requiredScope: ESI_ASSETS_SCOPE, maxIds: ESI_ASSET_HELPER_MAX_IDS, maxRows: ESI_ASSET_HELPER_MAX_IDS, maxResponseBytes: ESI_ASSET_LOCATION_MAX_RESPONSE_BYTES }, + characterWalletBalance: { method: "GET", path: "characters/{character_id}/wallet", pagination: "none", requiredScope: ESI_WALLET_SCOPE, maxRows: 1, maxResponseBytes: ESI_WALLET_MAX_RESPONSE_BYTES }, + marketPrices: { method: "GET", path: "markets/prices", pagination: "none", requiredScope: undefined, maxRows: ESI_MARKET_MAX_ROWS, maxResponseBytes: ESI_MARKET_MAX_RESPONSE_BYTES }, +} as const; +export type EsiReadOperation = keyof typeof ESI_READ_OPERATIONS; +/** Named alias for callers that want to inspect the allowlisted strategy set. */ +export const ESI_OPERATION_DESCRIPTORS = ESI_READ_OPERATIONS; const ASSET_CACHE_TTL_MS = 60 * 60 * 1000; +const ASSET_NAME_CACHE_TTL_MS = 60 * 60 * 1000; +const ASSET_LOCATION_CACHE_TTL_MS = 60 * 60 * 1000; +const WALLET_BALANCE_CACHE_TTL_MS = 120 * 1000; const DEFAULT_DEADLINE_MS = 15_000; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_RETRY_BASE_MS = 250; -const MAX_PAGES = 100_000; +const CACHE_STALE_RETENTION_MS = 6 * 60 * 60 * 1000; +export const ESI_CACHE_MAX_ENTRIES = 512; +export const ESI_CACHE_MAX_BYTES = 32 * 1024 * 1024; +export const ESI_ASSET_SNAPSHOT_MAX_ENTRIES = 64; +export const ESI_ASSET_SNAPSHOT_MAX_ROWS = 50_000; +export const ESI_COOLDOWN_MAX_ENTRIES = 512; +const INT64_MAX = 9_223_372_036_854_775_807n; -export type AuthorizationKey = - | string - | { - userId: string; - characterId?: number | string; - scopes?: readonly string[]; - }; +export type AuthorizationKey = { + /** Application user and resource principal are both required for private reads. */ + userId: string; + principal: string; + characterId: number; + scopes: readonly string[]; + grantRevision: number; + authorizationEpoch: number; + credentialVersion: number; +}; export type EsiMetric = { event: "request" | "cache" | "retry"; - method: "GET"; + method: "GET" | "POST"; endpoint: string; status?: number; attempt?: number; @@ -58,6 +95,8 @@ export type EsiAssetSnapshot = { assets: Asset[]; syncedAt: number; expiresAt: number; + /** ESI has no cross-page transaction; page-one revalidation is a bounded consistency check. */ + consistency: "bounded-non-atomic"; }; export type EsiMarketPrice = { @@ -72,6 +111,45 @@ export type EsiMarketSnapshot = { expiresAt: number; }; +export type EsiAssetName = { itemId: string; name: string }; +export type EsiAssetLocation = { + itemId: string; + position: { x: number; y: number; z: number }; +}; + +/** A helper response is intentionally explicit about IDs ESI did not return. */ +export type EsiAssetNamesObservation = { + requestedIds: readonly string[]; + names: readonly EsiAssetName[]; + missing: readonly string[]; + retrievedAt: number; + expiresAt: number; + complete: boolean; +}; +export type EsiAssetLocationsObservation = { + requestedIds: readonly string[]; + locations: readonly EsiAssetLocation[]; + missing: readonly string[]; + retrievedAt: number; + expiresAt: number; + complete: boolean; +}; + +/** Signed cents (T04 half-away-from-zero normalization) and fetch provenance. */ +export type EsiWalletBalanceObservation = { + amount: string; + retrievedAt: number; + expiresAt: number; +}; + +export type EsiCooldown = { + context: string; + until: number; + delayMs: number; + group?: string; + kind: "new" | "legacy" | "server"; +}; + export class EsiError extends Error { public readonly code: string; @@ -85,12 +163,14 @@ export class EsiError extends Error { export class EsiHttpError extends EsiError { public readonly status: number; public readonly endpoint: string; + public readonly method: EsiReadMethod; - public constructor(status: number, endpoint: string) { - super("http_error", `ESI GET ${endpoint} failed with HTTP ${status}`); + public constructor(status: number, endpoint: string, method: EsiReadMethod = "GET") { + super("http_error", `ESI ${method} ${endpoint} failed with HTTP ${status}`); this.name = "EsiHttpError"; this.status = status; this.endpoint = endpoint; + this.method = method; } } @@ -99,13 +179,16 @@ export class EsiRateLimitError extends EsiError { public readonly endpoint: string; public readonly retryAt: number; public readonly retryAfterMs: number; + /** Alias usable by a scheduler without inspecting an error message. */ + public readonly cooldown: EsiCooldown; - public constructor(endpoint: string, retryAt: number, retryAfterMs: number) { + public constructor(endpoint: string, retryAt: number, retryAfterMs: number, context = endpoint) { super("rate_limited", `ESI requested a retry for ${endpoint} after the request deadline`); this.name = "EsiRateLimitError"; this.endpoint = endpoint; this.retryAt = retryAt; this.retryAfterMs = retryAfterMs; + this.cooldown = { context, until: retryAt, delayMs: retryAfterMs, kind: "server" }; } } @@ -136,6 +219,7 @@ export class EsiStabilityError extends EsiError { type HeadersLike = Headers; type CacheEntry = { body: string; + bytes: number; etag?: string; lastModified?: string; cacheControl?: string; @@ -155,6 +239,22 @@ type RequestResult = { cacheable: boolean; }; +export type EsiReadMethod = "GET" | "POST"; +export type EsiSafeReadOptions = { + method: EsiReadMethod; + operation: EsiReadOperation; + accessToken?: string; + query?: Record; + body?: string; + force?: boolean; + fallbackTtlMs?: number; + rateLimitContext?: string; + deadlineAt?: number; + validateBody?: (body: string) => void; +}; +type SafeReadMethod = EsiReadMethod; +type SafeReadOptions = EsiSafeReadOptions; + const CHARACTER_LOCATION_FLAGS = new Set([ "AssetSafety", "AutoFit", "BoosterBay", "CapsuleerDeliveries", "Cargo", "CorporationGoalDeliveries", "CorpseBay", "Deliveries", "DroneBay", "ExpeditionHold", @@ -186,14 +286,14 @@ function losslessText(value: unknown): string | undefined { function integerText(value: unknown, field: string, endpoint: string): string { const text = losslessText(value); - if (!text || !/^-?(?:0|[1-9]\d*)$/.test(text)) { + if (!text || !/^(?:0|[1-9]\d*)$/.test(text)) { throw new EsiSchemaError(endpoint, `${field} must be an integer JSON number`); } try { const integer = BigInt(text); - if (integer < 0n) throw new Error("negative"); + if (integer < 0n || integer > INT64_MAX) throw new Error("outside signed int64"); } catch { - throw new EsiSchemaError(endpoint, `${field} must be a non-negative integer`); + throw new EsiSchemaError(endpoint, `${field} must be a bounded non-negative signed int64`); } return text; } @@ -211,6 +311,10 @@ function cloneAssets(assets: Asset[]): Asset[] { return assets.map((asset) => ({ ...asset })); } +function bodyByteLength(body: string): number { + return new TextEncoder().encode(body).byteLength; +} + function parseJson(body: string, endpoint: string): unknown { try { return parse(body); @@ -222,11 +326,14 @@ function parseJson(body: string, endpoint: string): unknown { function parseAssetPage(body: string, endpoint: string): Asset[] { const value = parseJson(body, endpoint); if (!Array.isArray(value)) throw new EsiSchemaError(endpoint, "body must be an array"); + if (value.length > ESI_ASSET_MAX_ROWS_PER_PAGE) { + throw new EsiError("response_too_large", `${endpoint} returned more than ${ESI_ASSET_MAX_ROWS_PER_PAGE} assets`); + } return value.map((raw, index) => { if (!isRecord(raw)) throw new EsiSchemaError(endpoint, `asset ${index} must be an object`); - const itemId = integerText(raw.item_id, `asset ${index}.item_id`, endpoint); - const locationId = integerText(raw.location_id, `asset ${index}.location_id`, endpoint); + const itemId = positiveDecimalId(raw.item_id, `asset ${index}.item_id`, endpoint); + const locationId = positiveDecimalId(raw.location_id, `asset ${index}.location_id`, endpoint); const typeId = safeInteger(raw.type_id, `asset ${index}.type_id`, endpoint); const quantity = safeInteger(raw.quantity, `asset ${index}.quantity`, endpoint); @@ -251,6 +358,7 @@ function parseAssetPage(body: string, endpoint: string): Asset[] { locationId, locationType: raw.location_type, locationFlag: raw.location_flag, + isSingleton: raw.is_singleton, ...(raw.is_blueprint_copy === undefined ? {} : { isBlueprintCopy: raw.is_blueprint_copy }), }; }); @@ -262,8 +370,8 @@ function parseXPages(headers: HeadersLike, endpoint: string, previous?: number): if (text === null) throw new EsiPaginationError(`${endpoint} did not provide X-Pages`); if (!/^\d+$/.test(text)) throw new EsiPaginationError(`${endpoint} returned an invalid X-Pages`); const pages = Number(text); - if (!Number.isSafeInteger(pages) || pages < 1 || pages > MAX_PAGES) { - throw new EsiPaginationError(`${endpoint} returned an unsafe X-Pages value`); + if (!Number.isSafeInteger(pages) || pages < 1 || pages > ESI_ASSET_MAX_PAGES) { + throw new EsiPaginationError(`${endpoint} returned an unsafe X-Pages value (cap ${ESI_ASSET_MAX_PAGES})`); } return pages; } @@ -281,6 +389,9 @@ function numberText(value: unknown, field: string, endpoint: string): string { function parseMarketPrices(body: string, endpoint: string): EsiMarketPrice[] { const value = parseJson(body, endpoint); if (!Array.isArray(value)) throw new EsiSchemaError(endpoint, "body must be an array"); + if (value.length > ESI_MARKET_MAX_ROWS) { + throw new EsiError("response_too_large", `${endpoint} returned more than ${ESI_MARKET_MAX_ROWS} prices`); + } const seen = new Set(); return value.map((raw, index) => { if (!isRecord(raw)) throw new EsiSchemaError(endpoint, `price ${index} must be an object`); @@ -295,6 +406,81 @@ function parseMarketPrices(body: string, endpoint: string): EsiMarketPrice[] { }); } +function positiveDecimalId(value: unknown, field: string, endpoint: string): string { + const text = integerText(value, field, endpoint); + try { + const id = BigInt(text); + if (id <= 0n || id > INT64_MAX) throw new Error("outside positive int64"); + } catch { + throw new EsiSchemaError(endpoint, `${field} must be a positive signed int64`); + } + return text; +} + +function parseAssetNames( + body: string, + endpoint: string, + requestedIds: readonly string[], +): { names: EsiAssetName[]; missing: string[] } { + const value = parseJson(body, endpoint); + if (!Array.isArray(value)) throw new EsiSchemaError(endpoint, "body must be an array"); + if (value.length > ESI_ASSET_HELPER_MAX_IDS) { + throw new EsiError("response_too_large", `${endpoint} returned more than ${ESI_ASSET_HELPER_MAX_IDS} names`); + } + const wanted = new Set(requestedIds); + const seen = new Set(); + const names = value.map((raw, index) => { + if (!isRecord(raw)) throw new EsiSchemaError(endpoint, `name ${index} must be an object`); + const itemId = positiveDecimalId(raw.item_id, `name ${index}.item_id`, endpoint); + if (!wanted.has(itemId)) throw new EsiSchemaError(endpoint, `name ${index}.item_id was not requested`); + if (seen.has(itemId)) throw new EsiSchemaError(endpoint, `duplicate item_id ${itemId}`); + seen.add(itemId); + if (typeof raw.name !== "string" || raw.name.length === 0) { + throw new EsiSchemaError(endpoint, `name ${index}.name must be a non-empty string`); + } + return { itemId, name: raw.name }; + }); + return { names, missing: requestedIds.filter((itemId) => !seen.has(itemId)) }; +} + +function parseAssetLocations( + body: string, + endpoint: string, + requestedIds: readonly string[], +): { locations: EsiAssetLocation[]; missing: string[] } { + const value = parseJson(body, endpoint); + if (!Array.isArray(value)) throw new EsiSchemaError(endpoint, "body must be an array"); + if (value.length > ESI_ASSET_HELPER_MAX_IDS) { + throw new EsiError("response_too_large", `${endpoint} returned more than ${ESI_ASSET_HELPER_MAX_IDS} locations`); + } + const wanted = new Set(requestedIds); + const seen = new Set(); + const locations = value.map((raw, index) => { + if (!isRecord(raw)) throw new EsiSchemaError(endpoint, `location ${index} must be an object`); + const itemId = positiveDecimalId(raw.item_id, `location ${index}.item_id`, endpoint); + if (!wanted.has(itemId)) throw new EsiSchemaError(endpoint, `location ${index}.item_id was not requested`); + if (seen.has(itemId)) throw new EsiSchemaError(endpoint, `duplicate item_id ${itemId}`); + seen.add(itemId); + if (!isRecord(raw.position)) throw new EsiSchemaError(endpoint, `location ${index}.position must be an object`); + const x = Number(numberText(raw.position.x, `location ${index}.position.x`, endpoint)); + const y = Number(numberText(raw.position.y, `location ${index}.position.y`, endpoint)); + const z = Number(numberText(raw.position.z, `location ${index}.position.z`, endpoint)); + if (![x, y, z].every(Number.isFinite)) throw new EsiSchemaError(endpoint, `location ${index}.position must be finite`); + return { itemId, position: { x, y, z } }; + }); + return { locations, missing: requestedIds.filter((itemId) => !seen.has(itemId)) }; +} + +function parseWalletBalance(body: string, endpoint: string): string { + const value = parseJson(body, endpoint); + const text = numberText(value, "wallet balance", endpoint); + try { + return decimalIskToCents(text); + } catch { + throw new EsiSchemaError(endpoint, "wallet balance must be a bounded signed decimal number"); + } +} + function headerNumber(headers: HeadersLike, name: string): number | undefined { const text = headers.get(name); if (text === null || !/^-?\d+(?:\.\d+)?$/.test(text.trim())) return undefined; @@ -397,6 +583,7 @@ function sameAssets(left: Asset[], right: Asset[]): boolean { && asset.locationId === other.locationId && asset.locationType === other.locationType && asset.locationFlag === other.locationFlag + && asset.isSingleton === other.isSingleton && asset.isBlueprintCopy === other.isBlueprintCopy; }); } @@ -404,19 +591,79 @@ function sameAssets(left: Asset[], right: Asset[]): boolean { function authorizationContext( characterId: number, authorizationKey: AuthorizationKey, + requiredScope?: string, ): string { - if (typeof authorizationKey === "string") { - if (authorizationKey.length === 0) throw new EsiError("authorization_context", "authorizationKey is required"); - return JSON.stringify({ userId: authorizationKey, characterId, scopes: ["*"] }); + if (!authorizationKey || typeof authorizationKey !== "object") { + throw new EsiError("authorization_context", "structured authorizationKey is required"); } - if (!authorizationKey || typeof authorizationKey.userId !== "string" || authorizationKey.userId.length === 0) { - throw new EsiError("authorization_context", "authorizationKey.userId is required"); + if (typeof authorizationKey.userId !== "string" || authorizationKey.userId.trim().length === 0 + || typeof authorizationKey.principal !== "string" || authorizationKey.principal.trim().length === 0) { + throw new EsiError("authorization_context", "authorizationKey userId and principal are required"); } - if (authorizationKey.characterId !== undefined && String(authorizationKey.characterId) !== String(characterId)) { + if (!Number.isSafeInteger(authorizationKey.characterId) || authorizationKey.characterId !== characterId) { throw new EsiError("authorization_context", "authorizationKey character does not match characterId"); } - const scopes = [...(authorizationKey.scopes ?? [])].map(String).sort(); - return JSON.stringify({ userId: authorizationKey.userId, characterId, scopes }); + if (!Array.isArray(authorizationKey.scopes) + || authorizationKey.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) { + throw new EsiError("authorization_context", "authorizationKey scopes must be non-empty strings"); + } + const scopes = [...new Set(authorizationKey.scopes)].sort(); + if (requiredScope !== undefined && !scopes.includes(requiredScope)) { + throw new EsiError("authorization_context", `authorizationKey is missing required scope ${requiredScope}`); + } + if (!Number.isSafeInteger(authorizationKey.grantRevision) || authorizationKey.grantRevision < 0 + || !Number.isSafeInteger(authorizationKey.authorizationEpoch) || authorizationKey.authorizationEpoch < 0 + || !Number.isSafeInteger(authorizationKey.credentialVersion) || authorizationKey.credentialVersion < 0) { + throw new EsiError("authorization_context", "authorization revisions, epoch, and credential version are required non-negative safe integers"); + } + return JSON.stringify({ + userId: authorizationKey.userId, + principal: authorizationKey.principal, + characterId, + scopes, + grantRevision: authorizationKey.grantRevision, + authorizationEpoch: authorizationKey.authorizationEpoch, + credentialVersion: authorizationKey.credentialVersion, + }); +} + +function canonicalAssetIds(ids: readonly (string | number | bigint)[], endpoint: string): string[] { + if (!Array.isArray(ids) || ids.length < 1 || ids.length > ESI_ASSET_HELPER_MAX_IDS) { + throw new EsiError("invalid_asset_ids", `${endpoint} accepts 1..${ESI_ASSET_HELPER_MAX_IDS} item IDs`); + } + const canonical = ids.map((value, index) => { + if (typeof value === "bigint") { + if (value <= 0n || value > INT64_MAX) throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} is outside positive int64 bounds`); + return value.toString(10); + } + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value <= 0) throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} must be a positive safe integer`); + const id = BigInt(value); + if (id > INT64_MAX) throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} is outside positive int64 bounds`); + return id.toString(10); + } + if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) { + throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} must be canonical decimal text`); + } + let id: bigint; + try { id = BigInt(value); } catch { throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} is invalid`); } + if (id <= 0n || id > INT64_MAX) throw new EsiError("invalid_asset_ids", `${endpoint} item ID ${index} is outside positive int64 bounds`); + return value; + }); + if (new Set(canonical).size !== canonical.length) { + throw new EsiError("invalid_asset_ids", `${endpoint} item IDs must be unique`); + } + canonical.sort((left, right) => { + const a = BigInt(left); const b = BigInt(right); + return a < b ? -1 : a > b ? 1 : 0; + }); + return canonical; +} + +function canonicalIdsBody(ids: readonly string[]): string { + // IDs are validated decimal literals, so this deliberately avoids JSON.stringify + // and its Number/BigInt coercion paths. + return `[${ids.join(",")}]`; } function validateCharacterId(characterId: number): void { @@ -439,7 +686,9 @@ export class EsiClient { private readonly random: () => number; private readonly observe?: EsiObserve; private readonly cache = new Map(); + private cacheBytes = 0; private readonly assetSnapshots = new Map(); + private assetSnapshotRows = 0; private readonly rateCooldowns = new Map(); private legacyRateCooldownUntil = 0; private marketSnapshot?: EsiMarketSnapshot; @@ -463,11 +712,58 @@ export class EsiClient { return url.toString(); } + private deleteCacheEntry(key: string): void { + const entry = this.cache.get(key); + if (entry) this.cacheBytes -= entry.bytes; + this.cache.delete(key); + } + + private deleteAssetSnapshot(key: string): void { + const snapshot = this.assetSnapshots.get(key); + if (snapshot) this.assetSnapshotRows -= snapshot.assets.length; + this.assetSnapshots.delete(key); + } + + private pruneState(now: number): void { + for (const [key, entry] of this.cache) { + if (entry.expiresAt + CACHE_STALE_RETENTION_MS <= now) this.deleteCacheEntry(key); + } + for (const [key, snapshot] of this.assetSnapshots) { + if (snapshot.expiresAt <= now) this.deleteAssetSnapshot(key); + } + for (const [key, until] of this.rateCooldowns) { + if (until <= now) this.rateCooldowns.delete(key); + } + while (this.cache.size > ESI_CACHE_MAX_ENTRIES || this.cacheBytes > ESI_CACHE_MAX_BYTES) { + this.deleteCacheEntry(this.cache.keys().next().value as string); + } + while (this.assetSnapshots.size > ESI_ASSET_SNAPSHOT_MAX_ENTRIES || this.assetSnapshotRows > ESI_ASSET_SNAPSHOT_MAX_ROWS) { + this.deleteAssetSnapshot(this.assetSnapshots.keys().next().value as string); + } + while (this.rateCooldowns.size > ESI_COOLDOWN_MAX_ENTRIES) this.rateCooldowns.delete(this.rateCooldowns.keys().next().value as string); + } + + private setCacheEntry(key: string, entry: CacheEntry): void { + this.pruneState(this.now()); + this.deleteCacheEntry(key); + this.cache.set(key, entry); + this.cacheBytes += entry.bytes; + this.pruneState(this.now()); + } + + private setAssetSnapshot(key: string, snapshot: EsiAssetSnapshot): void { + this.pruneState(this.now()); + this.deleteAssetSnapshot(key); + this.assetSnapshots.set(key, snapshot); + this.assetSnapshotRows += snapshot.assets.length; + this.pruneState(this.now()); + } + private cooldownDelay(rateLimitContext: string, now: number): number { + this.pruneState(now); let until = this.legacyRateCooldownUntil; for (const [key, value] of this.rateCooldowns) { - if (key.startsWith(`${rateLimitContext}|`) && value <= now) this.rateCooldowns.delete(key); - else if (key.startsWith(`${rateLimitContext}|`)) until = Math.max(until, value); + if (key.startsWith(`${rateLimitContext}|`)) until = Math.max(until, value); } return Math.max(0, until - now); } @@ -487,6 +783,7 @@ export class EsiClient { const retryAt = now + newLimiterDelay; const key = `${rateLimitContext}|new|${group}`; this.rateCooldowns.set(key, Math.max(this.rateCooldowns.get(key) ?? 0, retryAt)); + this.pruneState(now); } const legacyRemaining = headerNumber(headers, "x-esi-error-limit-remain"); @@ -509,51 +806,99 @@ export class EsiClient { deadlineAt: number, endpoint: string, rateLimited: boolean, + method: SafeReadMethod = "GET", + rateLimitContext = endpoint, ): Promise { const remainingBudget = Math.max(0, deadlineAt - this.now()); if (delayMs > remainingBudget) { - if (rateLimited) throw new EsiRateLimitError(endpoint, this.now() + delayMs, delayMs); - throw new EsiError("deadline", `ESI GET ${endpoint} retry budget exhausted`); + if (rateLimited) throw new EsiRateLimitError(endpoint, this.now() + delayMs, delayMs, rateLimitContext); + throw new EsiError("deadline", `ESI ${method} ${endpoint} retry budget exhausted`); } await this.sleep(delayMs); if (delayMs > 0 && this.now() >= deadlineAt) { - if (rateLimited) throw new EsiRateLimitError(endpoint, this.now(), delayMs); - throw new EsiError("deadline", `ESI GET ${endpoint} retry budget exhausted`); + if (rateLimited) throw new EsiRateLimitError(endpoint, this.now(), delayMs, rateLimitContext); + throw new EsiError("deadline", `ESI ${method} ${endpoint} retry budget exhausted`); } } - private async cancelBody(response: Response): Promise { + private cancelBody(response: Response, controller?: AbortController): void { + // Abort first, then start cancellation without awaiting an implementation + // supplied promise. A broken/slow body stream must not outlive the deadline. + controller?.abort(); try { - await response.body?.cancel(); + const cancellation = response.body?.cancel(); + if (cancellation) void cancellation.catch(() => {}); } catch { - // Cancellation is best effort; the request result is already being discarded. + // Cancellation is best effort; the request result is already discarded. } } - private async get( + private async readResponseBody( + response: Response, + maxBytes: number, + controller: AbortController, + deadlinePromise: Promise, + endpoint: string, + ): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + if (!/^\d+$/.test(contentLength.trim()) || Number(contentLength) > maxBytes) { + this.cancelBody(response, controller); + throw new EsiError("response_too_large", `ESI response for ${endpoint} exceeds ${maxBytes} bytes`); + } + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const result = await Promise.race([reader.read(), deadlinePromise]) as ReadableStreamReadResult; + if (result.done) break; + total += result.value.byteLength; + if (total > maxBytes) { + this.cancelBody(response, controller); + throw new EsiError("response_too_large", `ESI response for ${endpoint} exceeds ${maxBytes} bytes`); + } + chunks.push(result.value); + } + } finally { + try { reader.releaseLock(); } catch { /* already released */ } + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); + } + + private async read( path: string, cacheKey: string, - options: { - accessToken?: string; - query?: Record; - force?: boolean; - fallbackTtlMs?: number; - rateLimitContext?: string; - }, + options: SafeReadOptions, ): Promise { + const method = options.method; const endpoint = `${path}${options.query ? `?${new URLSearchParams(options.query).toString()}` : ""}`; + // Operation, method, query, body, and authorization context all form one + // private cache identity. This prevents a GET/POST or helper collision even + // if a future endpoint accidentally reuses a local label. + const requestCacheKey = JSON.stringify([options.operation, method, path, options.query ?? null, options.body ?? null, cacheKey]); const now = this.now(); - const cached = this.cache.get(cacheKey); + const deadlineAt = options.deadlineAt ?? now + DEFAULT_DEADLINE_MS; + if (deadlineAt <= now) throw new EsiError("deadline", `ESI ${method} ${endpoint} deadline exceeded`); + this.pruneState(now); + const cached = this.cache.get(requestCacheKey); if (cached && !options.force && cached.expiresAt > now) { - emit(this.observe, { event: "cache", method: "GET", endpoint, cache: "hit", status: 200 }); + emit(this.observe, { event: "cache", method, endpoint, cache: "hit", status: 200 }); return { body: cached.body, entry: cached, status: 200, fromCache: true, cacheable: true }; } const rateLimitContext = options.rateLimitContext ?? cacheKey; - const deadlineAt = now + DEFAULT_DEADLINE_MS; const existingCooldown = this.cooldownDelay(rateLimitContext, now); if (existingCooldown > 0) { - await this.sleepWithinBudget(existingCooldown, deadlineAt, endpoint, true); + await this.sleepWithinBudget(existingCooldown, deadlineAt, endpoint, true, method, rateLimitContext); } const url = this.endpoint(path, options.query); @@ -565,6 +910,7 @@ export class EsiClient { Accept: "application/json", "User-Agent": this.userAgent, "X-Compatibility-Date": ESI_COMPATIBILITY_DATE, + ...(method === "POST" ? { "Content-Type": "application/json" } : {}), }); if (options.accessToken !== undefined) headers.set("Authorization", `Bearer ${options.accessToken}`); if (cached?.etag) headers.set("If-None-Match", cached.etag); @@ -577,12 +923,17 @@ export class EsiClient { const deadlinePromise = new Promise((_, reject) => { rejectDeadline = reject; }); const deadline = setTimeout(() => { controller.abort(); - rejectDeadline(new EsiError("deadline", `ESI GET ${endpoint} deadline exceeded`)); + rejectDeadline(new EsiError("deadline", `ESI ${method} ${endpoint} deadline exceeded`)); }, attemptBudget); let response: Response; try { response = await Promise.race([ - this.requestFetch(url, { method: "GET", headers, signal: controller.signal }), + this.requestFetch(url, { + method, + headers, + ...(options.body === undefined ? {} : { body: options.body }), + signal: controller.signal, + }), deadlinePromise, ]); } catch (error) { @@ -590,7 +941,7 @@ export class EsiClient { const canRetry = retryCount < maxRetries; emit(this.observe, { event: "request", - method: "GET", + method, endpoint, attempt: retryCount + 1, retries: retryCount, @@ -600,12 +951,12 @@ export class EsiClient { const detail = controller.signal.aborted || (error instanceof Error && error.name === "AbortError") ? "deadline exceeded" : "network failure"; - throw new EsiError(detail === "deadline exceeded" ? "deadline" : "network", `ESI GET ${endpoint} ${detail}`); + throw new EsiError(detail === "deadline exceeded" ? "deadline" : "network", `ESI ${method} ${endpoint} ${detail}`); } const delay = DEFAULT_RETRY_BASE_MS * 2 ** retryCount + Math.floor(Math.max(0, Math.min(1, this.random())) * DEFAULT_RETRY_BASE_MS); retryCount += 1; - emit(this.observe, { event: "retry", method: "GET", endpoint, attempt: retryCount + 1, retries: retryCount, retryAfterMs: delay }); - await this.sleepWithinBudget(delay, deadlineAt, endpoint, false); + emit(this.observe, { event: "retry", method, endpoint, attempt: retryCount + 1, retries: retryCount, retryAfterMs: delay }); + await this.sleepWithinBudget(delay, deadlineAt, endpoint, false, method, rateLimitContext); continue; } @@ -615,7 +966,7 @@ export class EsiClient { const retryable = response.status === 408 || response.status === 420 || response.status === 425 || response.status === 429 || response.status >= 500; emit(this.observe, { event: "request", - method: "GET", + method, endpoint, status: response.status, attempt: retryCount + 1, @@ -635,20 +986,20 @@ export class EsiClient { const exponential = DEFAULT_RETRY_BASE_MS * 2 ** retryCount; const jitter = Math.floor(Math.max(0, Math.min(1, this.random())) * exponential); const delay = Math.max(limiterDelay ?? 0, exponential + jitter); - await this.cancelBody(response); + this.cancelBody(response, controller); clearTimeout(deadline); if (retryCount >= maxRetries) { - if (limiterDelay !== undefined) throw new EsiRateLimitError(endpoint, responseNow + limiterDelay, limiterDelay); - throw new EsiHttpError(response.status, endpoint); + if (limiterDelay !== undefined) throw new EsiRateLimitError(endpoint, responseNow + limiterDelay, limiterDelay, rateLimitContext); + throw new EsiHttpError(response.status, endpoint, method); } retryCount += 1; - emit(this.observe, { event: "retry", method: "GET", endpoint, attempt: retryCount + 1, retries: retryCount, retryAfterMs: delay }); - await this.sleepWithinBudget(delay, deadlineAt, endpoint, limiterDelay !== undefined); + emit(this.observe, { event: "retry", method, endpoint, attempt: retryCount + 1, retries: retryCount, retryAfterMs: delay }); + await this.sleepWithinBudget(delay, deadlineAt, endpoint, limiterDelay !== undefined, method, rateLimitContext); continue; } if (response.status === 304) { if (!cached) { - await this.cancelBody(response); + this.cancelBody(response, controller); clearTimeout(deadline); throw new EsiError("cache_miss", `ESI returned 304 without a cached body for ${endpoint}`); } @@ -666,36 +1017,59 @@ export class EsiClient { receivedAt: this.now(), expiresAt: expiryFromHeaders(response.headers, this.now(), options.fallbackTtlMs ?? ASSET_CACHE_TTL_MS, cached), }; - await this.cancelBody(response); + this.cancelBody(response, controller); clearTimeout(deadline); + try { + options.validateBody?.(entry.body); + } catch (error) { + this.deleteCacheEntry(requestCacheKey); + throw error; + } const cacheable = !hasNoStore(response.headers.get("cache-control") ?? cached.cacheControl); - if (cacheable) this.cache.set(cacheKey, entry); - else this.cache.delete(cacheKey); - emit(this.observe, { event: "cache", method: "GET", endpoint, cache: "304", status: 304 }); + if (cacheable) this.setCacheEntry(requestCacheKey, entry); + else this.deleteCacheEntry(requestCacheKey); + emit(this.observe, { event: "cache", method, endpoint, cache: "304", status: 304 }); return { body: entry.body, entry, status: 304, fromCache: false, cacheable }; } if (response.status < 200 || response.status >= 300) { - await this.cancelBody(response); + this.cancelBody(response, controller); clearTimeout(deadline); - throw new EsiHttpError(response.status, endpoint); + throw new EsiHttpError(response.status, endpoint, method); } let body: string; try { - body = await Promise.race([response.text(), deadlinePromise]); + body = await this.readResponseBody( + response, + ESI_READ_OPERATIONS[options.operation].maxResponseBytes, + controller, + deadlinePromise, + endpoint, + ); } catch (error) { - await this.cancelBody(response); + this.cancelBody(response, controller); clearTimeout(deadline); + if (error instanceof EsiError) { + this.deleteCacheEntry(requestCacheKey); + throw error; + } const detail = controller.signal.aborted || (error instanceof Error && error.name === "AbortError") ? "deadline exceeded" : "network failure while reading response"; - throw new EsiError(detail === "deadline exceeded" ? "deadline" : "network", `ESI GET ${endpoint} ${detail}`); + throw new EsiError(detail === "deadline exceeded" ? "deadline" : "network", `ESI ${method} ${endpoint} ${detail}`); } clearTimeout(deadline); + try { + options.validateBody?.(body); + } catch (error) { + this.deleteCacheEntry(requestCacheKey); + throw error; + } const cacheControl = response.headers.get("cache-control") ?? undefined; const cacheable = !hasNoStore(cacheControl); const entry: CacheEntry = { body, + bytes: bodyByteLength(body), etag: response.headers.get("etag") ?? undefined, lastModified: response.headers.get("last-modified") ?? undefined, cacheControl, @@ -708,8 +1082,8 @@ export class EsiClient { receivedAt: this.now(), expiresAt: expiryFromHeaders(response.headers, this.now(), options.fallbackTtlMs ?? ASSET_CACHE_TTL_MS), }; - if (cacheable) this.cache.set(cacheKey, entry); - else this.cache.delete(cacheKey); + if (cacheable) this.setCacheEntry(requestCacheKey, entry); + else this.deleteCacheEntry(requestCacheKey); return { body, entry, status: 200, fromCache: false, cacheable }; } } @@ -725,7 +1099,8 @@ export class EsiClient { ): Promise { validateCharacterId(characterId); validateAccessToken(accessToken); - const context = authorizationContext(characterId, authorizationKey); + const context = authorizationContext(characterId, authorizationKey, ESI_ASSETS_SCOPE); + const operationDeadlineAt = this.now() + DEFAULT_DEADLINE_MS; const snapshotKey = `assets:${context}`; const prior = this.assetSnapshots.get(snapshotKey); const now = this.now(); @@ -733,14 +1108,22 @@ export class EsiClient { const pagePath = `/characters/${characterId}/assets`; // The first page must be fetched without trusting an old page-count marker. - const firstResult = await this.get(pagePath, `asset:${context}:1`, { + const firstResult = await this.read(pagePath, `asset:${context}:1`, { + method: "GET", + operation: "characterAssets", accessToken, query: { page: "1" }, fallbackTtlMs: ASSET_CACHE_TTL_MS, rateLimitContext: context, + deadlineAt: operationDeadlineAt, + validateBody: (body) => { parseAssetPage(body, pagePath); }, }); const firstPages = this.readPageMetadata(firstResult, `${pagePath}?page=1`); const firstAssets = this.parsePageResult(firstResult, characterId, pagePath); + let totalRowsReceived = firstAssets.length; + if (totalRowsReceived > ESI_ASSET_MAX_ROWS) { + throw new EsiError("response_too_large", `ESI asset snapshot exceeds ${ESI_ASSET_MAX_ROWS} received rows`); + } const assets: Asset[] = []; const ids = new Set(); let duplicateItemId: string | undefined; @@ -756,15 +1139,23 @@ export class EsiClient { const pageEntries: CacheEntry[] = [firstResult.entry]; let allCacheable = firstResult.cacheable; for (let page = 2; page <= firstPages; page += 1) { - const result = await this.get(pagePath, `asset:${context}:${page}`, { + const result = await this.read(pagePath, `asset:${context}:${page}`, { + method: "GET", + operation: "characterAssets", accessToken, query: { page: String(page) }, fallbackTtlMs: ASSET_CACHE_TTL_MS, rateLimitContext: context, + deadlineAt: operationDeadlineAt, + validateBody: (body) => { parseAssetPage(body, pagePath); }, }); const pageCount = this.readPageMetadata(result, `${pagePath}?page=${page}`); if (pageCount !== firstPages) throw new EsiStabilityError(`X-Pages changed from ${firstPages} to ${pageCount}`); const pageAssets = this.parsePageResult(result, characterId, pagePath); + totalRowsReceived += pageAssets.length; + if (totalRowsReceived > ESI_ASSET_MAX_ROWS) { + throw new EsiError("response_too_large", `ESI asset snapshot exceeds ${ESI_ASSET_MAX_ROWS} received rows`); + } for (const asset of pageAssets) { if (ids.has(asset.itemId)) { duplicateItemId ??= asset.itemId; @@ -779,12 +1170,16 @@ export class EsiClient { // ESI explicitly does not promise a true multi-page snapshot. Revalidate // page one so a cache expiry or concurrent change cannot look complete. - const finalResult = await this.get(pagePath, `asset:${context}:1`, { + const finalResult = await this.read(pagePath, `asset:${context}:1`, { + method: "GET", + operation: "characterAssets", accessToken, query: { page: "1" }, force: true, fallbackTtlMs: ASSET_CACHE_TTL_MS, rateLimitContext: context, + deadlineAt: operationDeadlineAt, + validateBody: (body) => { parseAssetPage(body, pagePath); }, }); const finalPages = this.readPageMetadata(finalResult, `${pagePath}?page=1`); const finalAssets = this.parsePageResult(finalResult, characterId, pagePath); @@ -797,10 +1192,11 @@ export class EsiClient { assets: cloneAssets(assets), syncedAt: this.now(), expiresAt, + consistency: "bounded-non-atomic", }; allCacheable = allCacheable && finalResult.cacheable; - if (allCacheable) this.assetSnapshots.set(snapshotKey, snapshot); - else this.assetSnapshots.delete(snapshotKey); + if (allCacheable) this.setAssetSnapshot(snapshotKey, snapshot); + else this.deleteAssetSnapshot(snapshotKey); return { ...snapshot, assets: cloneAssets(snapshot.assets) }; } @@ -814,24 +1210,199 @@ export class EsiClient { return parseAssetPage(result.body, endpoint).map((asset) => ({ ...asset, characterId })); } + /** Return cooldown for the same structured private identity used by reads. */ + public getCooldown(authorizationKey: AuthorizationKey, characterId = authorizationKey.characterId, at = this.now()): EsiCooldown | null { + const context = authorizationContext(characterId, authorizationKey); + return this.getCooldownForContext(context, at); + } + + /** Public market reads use an explicit public limiter context. */ + public getPublicCooldown(at = this.now()): EsiCooldown | null { + return this.getCooldownForContext("public", at); + } + + public getCacheStats(at = this.now()): { + cacheEntries: number; + cacheBytes: number; + cacheBytesBudget: number; + assetSnapshots: number; + assetSnapshotRows: number; + assetSnapshotRowsBudget: number; + cooldowns: number; + } { + this.pruneState(at); + return { + cacheEntries: this.cache.size, + cacheBytes: this.cacheBytes, + cacheBytesBudget: ESI_CACHE_MAX_BYTES, + assetSnapshots: this.assetSnapshots.size, + assetSnapshotRows: this.assetSnapshotRows, + assetSnapshotRowsBudget: ESI_ASSET_SNAPSHOT_MAX_ROWS, + cooldowns: this.rateCooldowns.size, + }; + } + + private getCooldownForContext(rateLimitContext: string, at: number): EsiCooldown | null { + this.cooldownDelay(rateLimitContext, at); + let until = this.legacyRateCooldownUntil; + let group: string | undefined; + let kind: EsiCooldown["kind"] = "legacy"; + for (const [key, value] of this.rateCooldowns) { + if (!key.startsWith(`${rateLimitContext}|`)) continue; + const [, , candidateGroup] = key.split("|"); + if (value > until) { + until = value; + group = candidateGroup; + kind = "new"; + } + } + if (until <= at) return null; + return { context: rateLimitContext, until, delayMs: until - at, ...(group === undefined ? {} : { group }), kind }; + } + /** Internal market route adapter used by EsiMarketEstimates. */ public async getMarketPrices(): Promise { const prior = this.marketSnapshot; - const result = await this.get("/markets/prices", "market:prices", { + const result = await this.read("/markets/prices", "market:prices", { + method: "GET", + operation: "marketPrices", force: false, fallbackTtlMs: ASSET_CACHE_TTL_MS, rateLimitContext: "public", + validateBody: (body) => { parseMarketPrices(body, "/markets/prices"); }, }); const prices = parseMarketPrices(result.body, "/markets/prices"); const snapshot: EsiMarketSnapshot = { prices, - retrievedAt: prior && result.fromCache ? prior.retrievedAt : this.now(), + retrievedAt: prior && result.fromCache ? prior.retrievedAt : result.entry.receivedAt, expiresAt: result.entry.expiresAt, }; if (result.cacheable) this.marketSnapshot = snapshot; else this.marketSnapshot = undefined; return { ...snapshot, prices: snapshot.prices.map((price) => ({ ...price })) }; } + + /** + * Read the bounded, read-only character asset-name helper. The operation and + * body are fixed here; callers cannot turn this into an arbitrary POST. + */ + public async getAssetNames( + characterId: number, + itemIds: readonly (string | number | bigint)[], + accessToken: string, + authorizationKey: AuthorizationKey, + ): Promise; + public async getAssetNames( + characterId: number, + accessToken: string, + itemIds: readonly (string | number | bigint)[], + authorizationKey: AuthorizationKey, + ): Promise; + public async getAssetNames( + characterId: number, + idsOrToken: readonly (string | number | bigint)[] | string, + tokenOrIds: string | readonly (string | number | bigint)[], + authorizationKey: AuthorizationKey, + ): Promise { + const itemIds = typeof idsOrToken === "string" ? tokenOrIds as readonly (string | number | bigint)[] : idsOrToken; + const accessToken = typeof idsOrToken === "string" ? idsOrToken : tokenOrIds as string; + validateCharacterId(characterId); + validateAccessToken(accessToken); + const context = authorizationContext(characterId, authorizationKey, ESI_ASSETS_SCOPE); + const endpoint = `/characters/${characterId}/assets/names`; + const requestedIds = canonicalAssetIds(itemIds, endpoint); + const body = canonicalIdsBody(requestedIds); + const result = await this.read(endpoint, `asset-names:${context}:${body}`, { + method: "POST", + operation: "characterAssetNames", + accessToken, + body, + fallbackTtlMs: ASSET_NAME_CACHE_TTL_MS, + rateLimitContext: context, + validateBody: (responseBody) => { parseAssetNames(responseBody, endpoint, requestedIds); }, + }); + const parsed = parseAssetNames(result.body, endpoint, requestedIds); + return { + requestedIds: [...requestedIds], + names: parsed.names.map((entry) => ({ ...entry })), + missing: [...parsed.missing], + retrievedAt: result.entry.receivedAt, + expiresAt: result.entry.expiresAt, + complete: parsed.missing.length === 0, + }; + } + + /** Read the bounded, read-only character asset-location helper. */ + public async getAssetLocations( + characterId: number, + itemIds: readonly (string | number | bigint)[], + accessToken: string, + authorizationKey: AuthorizationKey, + ): Promise; + public async getAssetLocations( + characterId: number, + accessToken: string, + itemIds: readonly (string | number | bigint)[], + authorizationKey: AuthorizationKey, + ): Promise; + public async getAssetLocations( + characterId: number, + idsOrToken: readonly (string | number | bigint)[] | string, + tokenOrIds: string | readonly (string | number | bigint)[], + authorizationKey: AuthorizationKey, + ): Promise { + const itemIds = typeof idsOrToken === "string" ? tokenOrIds as readonly (string | number | bigint)[] : idsOrToken; + const accessToken = typeof idsOrToken === "string" ? idsOrToken : tokenOrIds as string; + validateCharacterId(characterId); + validateAccessToken(accessToken); + const context = authorizationContext(characterId, authorizationKey, ESI_ASSETS_SCOPE); + const endpoint = `/characters/${characterId}/assets/locations`; + const requestedIds = canonicalAssetIds(itemIds, endpoint); + const body = canonicalIdsBody(requestedIds); + const result = await this.read(endpoint, `asset-locations:${context}:${body}`, { + method: "POST", + operation: "characterAssetLocations", + accessToken, + body, + fallbackTtlMs: ASSET_LOCATION_CACHE_TTL_MS, + rateLimitContext: context, + validateBody: (responseBody) => { parseAssetLocations(responseBody, endpoint, requestedIds); }, + }); + const parsed = parseAssetLocations(result.body, endpoint, requestedIds); + return { + requestedIds: [...requestedIds], + locations: parsed.locations.map((entry) => ({ ...entry, position: { ...entry.position } })), + missing: [...parsed.missing], + retrievedAt: result.entry.receivedAt, + expiresAt: result.entry.expiresAt, + complete: parsed.missing.length === 0, + }; + } + + /** Read a single signed-ISK wallet balance; T04 normalizes sub-cent values. */ + public async getWalletBalance( + characterId: number, + accessToken: string, + authorizationKey: AuthorizationKey, + ): Promise { + validateCharacterId(characterId); + validateAccessToken(accessToken); + const context = authorizationContext(characterId, authorizationKey, ESI_WALLET_SCOPE); + const endpoint = `/characters/${characterId}/wallet`; + const result = await this.read(endpoint, `wallet-balance:${context}`, { + method: "GET", + operation: "characterWalletBalance", + accessToken, + fallbackTtlMs: WALLET_BALANCE_CACHE_TTL_MS, + rateLimitContext: context, + validateBody: (responseBody) => { parseWalletBalance(responseBody, endpoint); }, + }); + return { + amount: parseWalletBalance(result.body, endpoint), + retrievedAt: result.entry.receivedAt, + expiresAt: result.entry.expiresAt, + }; + } } function centsFromDecimal(value: string): string { diff --git a/src/lib/server/store.ts b/src/lib/server/store.ts index d6a6897..eabb0a0 100644 --- a/src/lib/server/store.ts +++ b/src/lib/server/store.ts @@ -47,6 +47,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; @@ -210,12 +218,15 @@ export class ConvexStore implements CharacterAssetRepository { scopes: string[]; expectedVersion: number; }, - ): Promise { - return await this.mutation(MUTATION.saveTokens, { + ): Promise { + const result = await this.mutation(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 { @@ -283,8 +294,26 @@ export class ConvexStore implements CharacterAssetRepository { throw new Error(SNAPSHOT_CHANGED); } - public async beginSync(userId: string, characterId: number): Promise { - return await this.mutation(MUTATION.beginSync, { userId, characterId }); + public async beginSync( + userId: string, + characterId: number, + expectedAuthority?: { + scopes: readonly string[]; + grantRevision: number; + authorizationEpoch: number; + credentialVersion?: number; + }, + ): Promise { + return await this.mutation(MUTATION.beginSync, { + userId, + characterId, + ...(expectedAuthority === undefined ? {} : { + expectedScopes: [...expectedAuthority.scopes], + expectedGrantRevision: expectedAuthority.grantRevision, + expectedAuthorizationEpoch: expectedAuthority.authorizationEpoch, + ...(expectedAuthority.credentialVersion === undefined ? {} : { expectedCredentialVersion: expectedAuthority.credentialVersion }), + }), + }); } public async stageAssets( diff --git a/tests/convex.test.ts b/tests/convex.test.ts index ae1a881..8bf7644 100644 --- a/tests/convex.test.ts +++ b/tests/convex.test.ts @@ -162,6 +162,33 @@ describe("Convex persistence", () => { })).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( diff --git a/tests/esi.test.ts b/tests/esi.test.ts index 43baf74..a1334a9 100644 --- a/tests/esi.test.ts +++ b/tests/esi.test.ts @@ -1,11 +1,24 @@ +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_READ_OPERATIONS, + ESI_WALLET_MAX_RESPONSE_BYTES, + ESI_WALLET_SCOPE, } from "../src/lib/server/esi"; const asset = (itemId = "9007199254740993", quantity = 2) => ({ @@ -22,6 +35,10 @@ function response(body: unknown, headers: Record = {}, 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 +63,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,13 +86,14 @@ 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"); }); @@ -97,12 +111,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,12 +127,265 @@ 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({ + 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 = `[{\"item_id\":1,\"name\":\"${"x".repeat(900_000)}\"}]`; + const weightedQueue = queueFetch(Array.from({ length: 40 }, () => response(weightedBody, { "Cache-Control": "max-age=60" }))); + const weighted = new EsiClient({ userAgent: "test", fetch: weightedQueue.fetch, now: () => 0 }); + for (let index = 0; index < 40; index += 1) await weighted.getAssetNames(7, ["1"], "token", authority(`weighted-${index}`, 7)); + const weightedStats = weighted.getCacheStats(); + expect(weightedStats.cacheBytes).toBeLessThanOrEqual(ESI_CACHE_MAX_BYTES); + expect(weightedStats.cacheEntries).toBeLessThan(40); + + 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); }); }); @@ -208,8 +475,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(); }); diff --git a/tests/fixtures/esi/asset-locations-finite.json b/tests/fixtures/esi/asset-locations-finite.json new file mode 100644 index 0000000..48701a3 --- /dev/null +++ b/tests/fixtures/esi/asset-locations-finite.json @@ -0,0 +1 @@ +[{"item_id":9007199254740993,"position":{"x":1,"y":-2,"z":0}}] diff --git a/tests/fixtures/esi/asset-names-int64.json b/tests/fixtures/esi/asset-names-int64.json new file mode 100644 index 0000000..e7ddcc9 --- /dev/null +++ b/tests/fixtures/esi/asset-names-int64.json @@ -0,0 +1 @@ +[{"item_id":9007199254740993,"name":"Freighter"}] diff --git a/tests/fixtures/esi/transport-metadata.json b/tests/fixtures/esi/transport-metadata.json new file mode 100644 index 0000000..0dd7076 --- /dev/null +++ b/tests/fixtures/esi/transport-metadata.json @@ -0,0 +1,33 @@ +{ + "compatibilityDate": "2026-08-18", + "evidence": { + "assetHelpers": { "source": "docs/research/eve-openapi-evidence.json", "verification": "pinned-authoritative" }, + "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" }, + "characterWalletBalance": { "route": "/characters/{character_id}/wallet", "method": "GET", "requiredScope": "esi-wallet.read_character_wallet.v1" } + } +} diff --git a/tests/fixtures/esi/wallet-balance-signed.json b/tests/fixtures/esi/wallet-balance-signed.json new file mode 100644 index 0000000..c359a9c --- /dev/null +++ b/tests/fixtures/esi/wallet-balance-signed.json @@ -0,0 +1 @@ +-9007199254740993.01