feat: add typed ESI transport extensions

This commit is contained in:
jb
2026-09-19 15:43:22 -04:00
parent 4d3c7a7521
commit 2675ea8114
14 changed files with 1205 additions and 124 deletions
+77 -18
View File
@@ -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
+12
View File
@@ -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.
+31 -1
View File
@@ -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");
+2
View File
@@ -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()),
});
+1
View File
@@ -95,6 +95,7 @@ function isFiniteSafeTypeId(value: unknown): value is number {
function validateAsset(asset: Asset, characterId: number): string | null {
if (!asset || typeof asset.itemId !== 'string' || !/^[1-9]\d*$/.test(asset.itemId)) return 'Asset item ID is invalid';
if (asset.characterId !== characterId) return 'Asset ownership validation failed';
if (asset.isSingleton !== undefined && typeof asset.isSingleton !== 'boolean') return 'Asset singleton flag is invalid';
if (!isFiniteSafeTypeId(asset.typeId)) return 'Asset type ID is invalid';
try {
validateQuantity(asset.quantity);
+53 -6
View File
@@ -91,8 +91,13 @@ export async function assetReport(
finally { reference?.close(); }
}
const refreshes = new Map<string, Promise<VerifiedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<VerifiedGrant> {
type CommittedGrant = VerifiedGrant & {
credentialVersion: number;
grantRevision: number;
authorizationEpoch: number;
};
const refreshes = new Map<string, Promise<CommittedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<CommittedGrant> {
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<VerifiedGrant>(record.encryptedTokens, `character:${characterId}`);
if (previous.characterId !== characterId) throw new Error('Character authorization mismatch');
if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous;
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);
File diff suppressed because it is too large Load Diff
+33 -4
View File
@@ -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<boolean> {
return await this.mutation<boolean>(MUTATION.saveTokens, {
): Promise<SavedTokenAuthority | false> {
const result = await this.mutation<boolean | SavedTokenAuthority>(MUTATION.saveTokens, {
userId,
characterId,
...input,
returnAuthority: true,
});
if (result === true) throw new Error("Token save did not return committed authority");
return result;
}
public async characters(userId: UserId): Promise<Character[]> {
@@ -283,8 +294,26 @@ export class ConvexStore implements CharacterAssetRepository {
throw new Error(SNAPSHOT_CHANGED);
}
public async beginSync(userId: string, characterId: number): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, { userId, characterId });
public async beginSync(
userId: string,
characterId: number,
expectedAuthority?: {
scopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion?: number;
},
): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, {
userId,
characterId,
...(expectedAuthority === undefined ? {} : {
expectedScopes: [...expectedAuthority.scopes],
expectedGrantRevision: expectedAuthority.grantRevision,
expectedAuthorizationEpoch: expectedAuthority.authorizationEpoch,
...(expectedAuthority.credentialVersion === undefined ? {} : { expectedCredentialVersion: expectedAuthority.credentialVersion }),
}),
});
}
public async stageAssets(
+27
View File
@@ -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(
+282 -15
View File
@@ -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<string, string> = {}, status =
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status, headers });
}
function authority(userId: string, characterId: number, scopes: readonly string[] = [ESI_ASSETS_SCOPE], grantRevision = 1, authorizationEpoch = 1, credentialVersion = 1) {
return { userId, principal: `${userId}:principal`, characterId, scopes, grantRevision, authorizationEpoch, credentialVersion } as const;
}
function queueFetch(responses: Response[]) {
const calls: { url: string; init: RequestInit }[] = [];
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -46,11 +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<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("x".repeat(ESI_WALLET_MAX_RESPONSE_BYTES + 1)));
controller.close();
},
});
const streamQueue = queueFetch([new Response(stream)]);
const streamClient = new EsiClient({ userAgent: "test", fetch: streamQueue.fetch, now: () => 0 });
await expect(streamClient.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]))).rejects.toMatchObject({ code: "response_too_large" });
const oversizedPage = JSON.stringify(Array.from({ length: ESI_ASSET_MAX_ROWS_PER_PAGE + 1 }, () => ({})));
const pageQueue = queueFetch([response(oversizedPage, { "X-Pages": "1" })]);
const pageClient = new EsiClient({ userAgent: "test", fetch: pageQueue.fetch, now: () => 0 });
await expect(pageClient.getAssets(7, "token", authority("u", 7))).rejects.toMatchObject({ code: "response_too_large" });
expect(pageClient.getCacheStats().cacheEntries).toBe(0);
});
test("rejects malformed helper arrays, duplicate/unexpected IDs, malformed wallet, and negative zero", async () => {
const queued = queueFetch([
response("[{\"item_id\":1,\"name\":\"a\"},{\"item_id\":1,\"name\":\"b\"}]"),
response("[{\"item_id\":2,\"name\":\"a\"}]"),
response("{}"),
response("not-a-number"),
]);
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
await expect(client.getAssetNames(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetNames(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetLocations(7, ["1"], "token", authority("u", 7))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getWalletBalance(7, "token", authority("u", 7, [ESI_WALLET_SCOPE]))).rejects.toBeInstanceOf(EsiSchemaError);
await expect(client.getAssetNames(7, ["-0"], "token", authority("u", 7))).rejects.toMatchObject({ code: "invalid_asset_ids" });
});
test("records cooldowns on successful responses, exposes typed queries, and bounds maps", async () => {
let now = 0;
const cooldownAuth = authority("u", 7, [ESI_WALLET_SCOPE]);
const cooldownQueue = queueFetch([
response("1", { "Cache-Control": "max-age=0", "X-Ratelimit-Remaining": "0", "X-Ratelimit-Group": "wallet", "Retry-After": "10" }),
response("1", { "Cache-Control": "max-age=60" }),
]);
const sleeps: number[] = [];
const client = new EsiClient({ userAgent: "test", fetch: cooldownQueue.fetch, now: () => now, sleep: async (ms) => { sleeps.push(ms); } });
await client.getWalletBalance(7, "token", cooldownAuth);
expect(client.getCooldown(cooldownAuth, 7)).toMatchObject({ delayMs: 10_000, group: "wallet", kind: "new" });
now = 1_000;
await client.getWalletBalance(7, "token", cooldownAuth);
expect(sleeps).toContain(9_000);
const thrownQueue = queueFetch([response("rate", { "Retry-After": "20", "X-Ratelimit-Remaining": "0" }, 429)]);
const thrown = new EsiClient({ userAgent: "test", fetch: thrownQueue.fetch, now: () => 0, sleep: async () => {} });
await expect(thrown.getWalletBalance(7, "token", cooldownAuth)).rejects.toMatchObject({ code: "rate_limited", cooldown: { delayMs: 20_000 } });
const boundedQueue = queueFetch(Array.from({ length: 520 }, (_, index) => response(`[{\"item_id\":${index + 1},\"name\":\"x\"}]`, { "Cache-Control": "max-age=60" })));
const bounded = new EsiClient({ userAgent: "test", fetch: boundedQueue.fetch, now: () => 0 });
for (let index = 0; index < 520; index += 1) await bounded.getAssetNames(7, [String(index + 1)], "token", authority("u", 7));
expect(bounded.getCacheStats().cacheEntries).toBeLessThanOrEqual(512);
expect(bounded.getCacheStats(7 * 60 * 60 * 1000).cacheEntries).toBe(0);
const weightedBody = `[{\"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();
});
+1
View File
@@ -0,0 +1 @@
[{"item_id":9007199254740993,"position":{"x":1,"y":-2,"z":0}}]
+1
View File
@@ -0,0 +1 @@
[{"item_id":9007199254740993,"name":"Freighter"}]
+33
View File
@@ -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" }
}
}
+1
View File
@@ -0,0 +1 @@
-9007199254740993.01