feat: add typed ESI transport extensions
This commit is contained in:
+77
-18
@@ -1,9 +1,11 @@
|
|||||||
# ESI operations
|
# ESI operations
|
||||||
|
|
||||||
The server ESI adapter uses the fixed trusted base `https://esi.evetech.net` and pins
|
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
|
`X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes a fixed
|
||||||
character asset GET and global market-price GET needed by the application; callers
|
allowlist of typed safe reads: character assets (paged GET), character asset names
|
||||||
cannot provide an arbitrary ESI URL.
|
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
|
## 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
|
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
|
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
|
fails the operation. The caller must not persist a returned partial result: the
|
||||||
method returns only a complete `{ assets, syncedAt, expiresAt }` snapshot. Asset
|
method returns only a complete bounded-read snapshot with
|
||||||
IDs are retained as decimal strings, and the response is parsed with
|
`consistency: "bounded-non-atomic"`. Asset IDs are retained as decimal strings,
|
||||||
`lossless-json` so an int64 cannot be rounded by JavaScript.
|
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
|
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.
|
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
|
Only page one is conditionally revalidated: revalidating every page would multiply
|
||||||
`getAssets` resolves. Convex persistence is intentionally outside this adapter.
|
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,
|
The cache is private to an `EsiClient` and keyed by a structured authorization
|
||||||
character, and scopes. `authorizationKey` should therefore identify the linked
|
context: application user/principal, character, sorted scopes, `grantRevision`,
|
||||||
application user and include the character/scopes when available:
|
`authorizationEpoch`, and committed `credentialVersion`. Production callers pass
|
||||||
|
the current counters:
|
||||||
|
|
||||||
```ts
|
```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
|
Tokens are never used as cache keys or emitted in metrics. Private reads reject
|
||||||
an isolated user key. Tokens are never used as cache keys or emitted in metrics.
|
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 retain their raw text, ETag, Last-Modified, and cache metadata. Fresh
|
||||||
responses use the server TTL; stale responses send `If-None-Match` and
|
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
|
`Date`, and `Age` are honored. `no-store` responses retain neither body nor
|
||||||
validators. Requests have one deadline covering response-body reads and bounded
|
validators. Requests have one deadline covering response-body reads and bounded
|
||||||
retries for transient network/5xx/420/429 responses. `Retry-After` (seconds or
|
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
|
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
|
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
|
error limiter is cooled down globally. Query a private cooldown with
|
||||||
request budget returns a typed rate-limit error rather than retrying early. 401
|
`client.getCooldown(authorizationKey, characterId)` or the public limiter with
|
||||||
and 403 are never blindly retried.
|
`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
|
## Market estimates
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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
|
## Execution model
|
||||||
|
|
||||||
- One task per branch/worktree; branch suggestions below are labels, not created branches.
|
- One task per branch/worktree; branch suggestions below are labels, not created branches.
|
||||||
|
|||||||
+31
-1
@@ -540,6 +540,7 @@ export const bridgeSaveTokens = mutationGeneric({
|
|||||||
tokenExpiresAt: v.number(),
|
tokenExpiresAt: v.number(),
|
||||||
scopes: v.array(v.string()),
|
scopes: v.array(v.string()),
|
||||||
expectedVersion: v.number(),
|
expectedVersion: v.number(),
|
||||||
|
returnAuthority: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
@@ -569,6 +570,15 @@ export const bridgeSaveTokens = mutationGeneric({
|
|||||||
datasetStatuses: updated.statuses,
|
datasetStatuses: updated.statuses,
|
||||||
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
|
...(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;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -713,7 +723,14 @@ export const bridgeAssetsPage = queryGeneric({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const bridgeBeginSync = mutationGeneric({
|
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) => {
|
handler: async (ctx, args) => {
|
||||||
assertBridge(args.bridgeSecret);
|
assertBridge(args.bridgeSecret);
|
||||||
assertDeploymentCapability('asset_sync');
|
assertDeploymentCapability('asset_sync');
|
||||||
@@ -722,6 +739,19 @@ export const bridgeBeginSync = mutationGeneric({
|
|||||||
if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) {
|
if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) {
|
||||||
throw new Error("Asset authorization is not active");
|
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();
|
const timestamp = now();
|
||||||
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) {
|
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) {
|
||||||
throw new Error("Character synchronization is already leased");
|
throw new Error("Character synchronization is already leased");
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const assetValidator = v.object({
|
|||||||
locationId: v.string(),
|
locationId: v.string(),
|
||||||
locationType: v.string(),
|
locationType: v.string(),
|
||||||
locationFlag: 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()),
|
isBlueprintCopy: v.optional(v.boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ function isFiniteSafeTypeId(value: unknown): value is number {
|
|||||||
function validateAsset(asset: Asset, characterId: number): string | null {
|
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 || 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.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';
|
if (!isFiniteSafeTypeId(asset.typeId)) return 'Asset type ID is invalid';
|
||||||
try {
|
try {
|
||||||
validateQuantity(asset.quantity);
|
validateQuantity(asset.quantity);
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ export async function assetReport(
|
|||||||
finally { reference?.close(); }
|
finally { reference?.close(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshes = new Map<string, Promise<VerifiedGrant>>();
|
type CommittedGrant = VerifiedGrant & {
|
||||||
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<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 key = `${userId}:${characterId}`;
|
||||||
const existing = refreshes.get(key); if (existing) return existing;
|
const existing = refreshes.get(key); if (existing) return existing;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
@@ -101,7 +106,16 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn
|
|||||||
if (!record) throw new Error('Character authorization is missing');
|
if (!record) throw new Error('Character authorization is missing');
|
||||||
const previous = await cipher.open<VerifiedGrant>(record.encryptedTokens, `character:${characterId}`);
|
const previous = await cipher.open<VerifiedGrant>(record.encryptedTokens, `character:${characterId}`);
|
||||||
if (previous.characterId !== characterId) throw new Error('Character authorization mismatch');
|
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);
|
const grant = await sso.refresh(previous.refreshToken);
|
||||||
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) {
|
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) {
|
||||||
await store.quarantineAuthorization(userId, characterId).catch(() => {});
|
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 });
|
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');
|
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);
|
refreshes.set(key, promise);
|
||||||
try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); }
|
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.
|
// that actually authorizes the ESI request and publication.
|
||||||
const grant = await characterGrant(userId, character.id, true);
|
const grant = await characterGrant(userId, character.id, true);
|
||||||
if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing');
|
if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing');
|
||||||
generation = await store.beginSync(userId, character.id);
|
// Refresh/CAS may have advanced authority while the initial character
|
||||||
const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`);
|
// 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);
|
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));
|
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);
|
await store.finishSync(userId, character.id, generation, chunks, snapshot.syncedAt, snapshot.expiresAt);
|
||||||
|
|||||||
+651
-80
File diff suppressed because it is too large
Load Diff
+33
-4
@@ -47,6 +47,14 @@ export type TokenRecord = {
|
|||||||
scopes: string[];
|
scopes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SavedTokenAuthority = {
|
||||||
|
saved: true;
|
||||||
|
credentialVersion: number;
|
||||||
|
grantRevision: number;
|
||||||
|
authorizationEpoch: number;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SaveInteractionInput = {
|
export type SaveInteractionInput = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -210,12 +218,15 @@ export class ConvexStore implements CharacterAssetRepository {
|
|||||||
scopes: string[];
|
scopes: string[];
|
||||||
expectedVersion: number;
|
expectedVersion: number;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<SavedTokenAuthority | false> {
|
||||||
return await this.mutation<boolean>(MUTATION.saveTokens, {
|
const result = await this.mutation<boolean | SavedTokenAuthority>(MUTATION.saveTokens, {
|
||||||
userId,
|
userId,
|
||||||
characterId,
|
characterId,
|
||||||
...input,
|
...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[]> {
|
public async characters(userId: UserId): Promise<Character[]> {
|
||||||
@@ -283,8 +294,26 @@ export class ConvexStore implements CharacterAssetRepository {
|
|||||||
throw new Error(SNAPSHOT_CHANGED);
|
throw new Error(SNAPSHOT_CHANGED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async beginSync(userId: string, characterId: number): Promise<string> {
|
public async beginSync(
|
||||||
return await this.mutation<string>(MUTATION.beginSync, { userId, characterId });
|
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(
|
public async stageAssets(
|
||||||
|
|||||||
@@ -162,6 +162,33 @@ describe("Convex persistence", () => {
|
|||||||
})).rejects.toThrow("not eligible");
|
})).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 () => {
|
test("OAuth state is one-time and token writes use CAS", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
+282
-15
@@ -1,11 +1,24 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
EsiClient,
|
EsiClient,
|
||||||
|
EsiError,
|
||||||
EsiHttpError,
|
EsiHttpError,
|
||||||
EsiMarketEstimates,
|
EsiMarketEstimates,
|
||||||
|
EsiSchemaError,
|
||||||
|
ESI_ASSET_MAX_PAGES,
|
||||||
EsiRateLimitError,
|
EsiRateLimitError,
|
||||||
EsiStabilityError,
|
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_COMPATIBILITY_DATE,
|
||||||
|
ESI_READ_OPERATIONS,
|
||||||
|
ESI_WALLET_MAX_RESPONSE_BYTES,
|
||||||
|
ESI_WALLET_SCOPE,
|
||||||
} from "../src/lib/server/esi";
|
} from "../src/lib/server/esi";
|
||||||
|
|
||||||
const asset = (itemId = "9007199254740993", quantity = 2) => ({
|
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 });
|
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[]) {
|
function queueFetch(responses: Response[]) {
|
||||||
const calls: { url: string; init: RequestInit }[] = [];
|
const calls: { url: string; init: RequestInit }[] = [];
|
||||||
const fetch = async (input: RequestInfo | URL, 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" } }),
|
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 client = new EsiClient({ userAgent: "eden-test", fetch: queued.fetch, now: () => 1_000 });
|
||||||
const result = await client.getAssets(42, "secret-token", {
|
const result = await client.getAssets(42, "secret-token", authority("user-a", 42));
|
||||||
userId: "user-a",
|
|
||||||
characterId: 42,
|
|
||||||
scopes: ["esi-assets.read_assets.v1"],
|
|
||||||
});
|
|
||||||
expect(result.assets.map((item) => item.itemId)).toEqual(["9007199254740993", "2"]);
|
expect(result.assets.map((item) => item.itemId)).toEqual(["9007199254740993", "2"]);
|
||||||
expect(result.assets[0]?.characterId).toBe(42);
|
expect(result.assets[0]?.characterId).toBe(42);
|
||||||
expect(queued.calls).toHaveLength(3);
|
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" } }),
|
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 });
|
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;
|
now = 2_000;
|
||||||
await client.getAssets(7, "a", { userId: "one", scopes: ["assets"] });
|
await client.getAssets(7, "a", authority("one", 7));
|
||||||
await client.getAssets(7, "b", { userId: "two", scopes: ["assets"] });
|
await client.getAssets(7, "b", authority("two", 7));
|
||||||
expect(queued.calls).toHaveLength(6);
|
expect(queued.calls).toHaveLength(6);
|
||||||
expect(new Headers(queued.calls[1]?.init.headers).get("if-none-match")).toBe('"a"');
|
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");
|
expect(JSON.stringify(queued.calls)).not.toContain("secret");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,12 +111,12 @@ describe("central ESI client", () => {
|
|||||||
random: () => 0,
|
random: () => 0,
|
||||||
now: () => 0,
|
now: () => 0,
|
||||||
});
|
});
|
||||||
await client.getAssets(8, "token", "u");
|
await client.getAssets(8, "token", authority("u", 8));
|
||||||
expect(delays[0]).toBe(2_000);
|
expect(delays[0]).toBe(2_000);
|
||||||
|
|
||||||
const denied = queueFetch([response({}, {}, 401)]);
|
const denied = queueFetch([response({}, {}, 401)]);
|
||||||
const deniedClient = new EsiClient({ userAgent: "test", fetch: denied.fetch, now: () => 0 });
|
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);
|
expect(denied.calls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,12 +127,265 @@ describe("central ESI client", () => {
|
|||||||
response([asset("3")], { "X-Pages": "2", ETag: '"three"' }),
|
response([asset("3")], { "X-Pages": "2", ETag: '"three"' }),
|
||||||
]);
|
]);
|
||||||
const client = new EsiClient({ userAgent: "test", fetch: changing.fetch, now: () => 0 });
|
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);
|
expect(changing.calls).toHaveLength(3);
|
||||||
|
|
||||||
const invalid = queueFetch([response([{ item_id: 1 }], { "X-Pages": "1" })]);
|
const invalid = queueFetch([response([{ item_id: 1 }], { "X-Pages": "1" })]);
|
||||||
const invalidClient = new EsiClient({ userAgent: "test", fetch: invalid.fetch, now: () => 0 });
|
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" }),
|
response(body, { "Cache-Control": "no-store", ETag: '"nope2"', "X-Pages": "1" }),
|
||||||
]);
|
]);
|
||||||
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
|
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 0 });
|
||||||
await client.getAssets(10, "token", "u");
|
await client.getAssets(10, "token", authority("u", 10));
|
||||||
await client.getAssets(10, "token", "u");
|
await client.getAssets(10, "token", authority("u", 10));
|
||||||
expect(queued.calls).toHaveLength(4);
|
expect(queued.calls).toHaveLength(4);
|
||||||
expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBeNull();
|
expect(new Headers(queued.calls[2]?.init.headers).get("if-none-match")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"item_id":9007199254740993,"position":{"x":1,"y":-2,"z":0}}]
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
[{"item_id":9007199254740993,"name":"Freighter"}]
|
||||||
+33
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-9007199254740993.01
|
||||||
Reference in New Issue
Block a user