wip: checkpoint reviewed T13 wallet balance core
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { EsiWalletBalanceObservation } from '../esi';
|
||||
import { ESI_WALLET_SCOPE, EsiError } from '../esi';
|
||||
import { parseIsk } from '../../domain/money';
|
||||
import {
|
||||
sameWalletFence,
|
||||
validateWalletAmount,
|
||||
validateWalletAuthorityFence,
|
||||
validateWalletSnapshot,
|
||||
type WalletAuthorityFence,
|
||||
type WalletBalanceRepository,
|
||||
type WalletBalanceSnapshot,
|
||||
type WalletCredential,
|
||||
type WalletFailurePublication,
|
||||
type WalletPublicationResult,
|
||||
type WalletSnapshotPublication,
|
||||
} from './balance-repository';
|
||||
|
||||
export const WALLET_BALANCE_DATASET = 'wallet_balance' as const;
|
||||
export const DEFAULT_WALLET_JOB_LEASE_MS = 60_000;
|
||||
export const DEFAULT_WALLET_QUEUE_LIMIT = 100;
|
||||
export const DEFAULT_WALLET_COOLDOWN_MS = 0;
|
||||
|
||||
export type WalletJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'quarantined';
|
||||
|
||||
/** A serialized, character-scoped job. The fence is the only job authority. */
|
||||
export type WalletBalanceJob = {
|
||||
id: string;
|
||||
dataset: typeof WALLET_BALANCE_DATASET;
|
||||
owner: { kind: 'user'; userId: string };
|
||||
authority: 'user';
|
||||
characterId: number;
|
||||
fence: WalletAuthorityFence;
|
||||
status: WalletJobStatus;
|
||||
attempts: number;
|
||||
dueAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
leaseExpiresAt: number | null;
|
||||
workerId: string | null;
|
||||
leaseToken: string | null;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export type WalletEnqueueRequest = {
|
||||
userId: string;
|
||||
characterId: number;
|
||||
fence: WalletAuthorityFence;
|
||||
dueAt?: number;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type WalletEnqueueResult =
|
||||
| { status: 'queued'; job: WalletBalanceJob }
|
||||
| { status: 'duplicate'; job: WalletBalanceJob }
|
||||
| { status: 'cooldown'; job: null; dueAt: number }
|
||||
| { status: 'queue_full'; job: null; dueAt: number };
|
||||
|
||||
export type WalletBalanceClaim = WalletBalanceJob & {
|
||||
status: 'running';
|
||||
workerId: string;
|
||||
leaseToken: string;
|
||||
leaseExpiresAt: number;
|
||||
};
|
||||
|
||||
export interface WalletBalanceJobQueue {
|
||||
enqueue(request: WalletEnqueueRequest): Promise<WalletEnqueueResult>;
|
||||
claim(workerId: string, now?: number): Promise<WalletBalanceClaim | null>;
|
||||
renew(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number, leaseMs?: number): Promise<boolean>;
|
||||
complete(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, status: 'succeeded' | 'failed' | 'cancelled' | 'quarantined', now?: number, error?: string): Promise<boolean>;
|
||||
defer(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, dueAt: number, now?: number): Promise<boolean>;
|
||||
isLive(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number): Promise<boolean>;
|
||||
/** Returns the canonical stored claim; caller-supplied owner/fence fields are never trusted. */
|
||||
canonicalClaim(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now?: number): Promise<WalletBalanceClaim | null>;
|
||||
cooldown(userId: string, characterId: number, now?: number): Promise<{ until: number; group: string } | null>;
|
||||
setCooldown(userId: string, characterId: number, until: number, group?: string): Promise<void>;
|
||||
/** Server/adapter clock used by atomic fixture publication; event timestamps are not authority. */
|
||||
now(): number;
|
||||
cooldownAfterSuccess(userId: string, characterId: number, now?: number): Promise<void>;
|
||||
get(jobId: string): Promise<WalletBalanceJob | null>;
|
||||
}
|
||||
|
||||
/** Typed transport shape matching EsiClient.getWalletBalance; no URL or OAuth logic is added. */
|
||||
export type WalletBalanceTransport = {
|
||||
getWalletBalance(characterId: number, accessToken: string, authorizationKey: import('../esi').AuthorizationKey, signal?: AbortSignal): Promise<EsiWalletBalanceObservation>;
|
||||
};
|
||||
|
||||
/** A repository/credential backend was unavailable; this is not an auth decision. */
|
||||
export class WalletAuthorityUnavailableError extends Error {
|
||||
public readonly code = 'authority_unavailable';
|
||||
public readonly retryable = true;
|
||||
|
||||
public constructor(message = 'wallet authority backend is unavailable') {
|
||||
super(message);
|
||||
this.name = 'WalletAuthorityUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A worker lease was lost while remote I/O was in flight; publication is forbidden. */
|
||||
export class WalletLeaseLostError extends Error {
|
||||
public readonly code = 'lease_lost';
|
||||
|
||||
public constructor() {
|
||||
super('wallet worker lease was lost before publication');
|
||||
this.name = 'WalletLeaseLostError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads current authority and an already-issued credential; it cannot grant scopes. */
|
||||
export type WalletBalanceAuthorityPort = Pick<WalletBalanceRepository, 'currentAuthority' | 'credential'>;
|
||||
/** Atomic publication seam for a durable queue/store adapter. */
|
||||
export type WalletBalancePublicationPort = {
|
||||
publishSuccess(input: WalletSnapshotPublication): Promise<WalletPublicationResult>;
|
||||
publishFailure(input: WalletFailurePublication): Promise<WalletPublicationResult>;
|
||||
};
|
||||
|
||||
export type WalletBalanceJobAdapterOptions = {
|
||||
jobs: WalletBalanceJobQueue;
|
||||
authority: WalletBalanceAuthorityPort;
|
||||
publication: WalletBalancePublicationPort;
|
||||
transport: WalletBalanceTransport;
|
||||
now?: () => number;
|
||||
leaseMs?: number;
|
||||
/** Renew at most this often while transport/publication I/O is pending. */
|
||||
heartbeatMs?: number;
|
||||
};
|
||||
|
||||
export type WalletJobRunResult =
|
||||
| { status: 'idle' }
|
||||
| { status: 'published'; job: WalletBalanceJob; publication: WalletPublicationResult }
|
||||
| { status: 'rejected'; job: WalletBalanceJob; reason: string }
|
||||
| { status: 'unavailable'; job: WalletBalanceJob; error: string }
|
||||
| { status: 'failed'; job: WalletBalanceJob; error: string };
|
||||
|
||||
function validNow(value: number): number {
|
||||
if (!Number.isFinite(value) || value < 0) throw new RangeError('wallet job clock returned an invalid timestamp');
|
||||
return value;
|
||||
}
|
||||
|
||||
function cloneFence(fence: WalletAuthorityFence): WalletAuthorityFence {
|
||||
return { ...fence, principal: { ...fence.principal }, scopes: [...fence.scopes] };
|
||||
}
|
||||
|
||||
function validateFence(fence: WalletAuthorityFence): void {
|
||||
try { validateWalletAuthorityFence(fence); } catch { throw new TypeError('wallet job authority fence is invalid'); }
|
||||
}
|
||||
|
||||
function validateJob(job: WalletBalanceJob): void {
|
||||
if (!job || typeof job.id !== 'string' || !job.id || job.dataset !== WALLET_BALANCE_DATASET || job.authority !== 'user' || job.owner.kind !== 'user' || job.owner.userId !== job.fence.userId || job.characterId !== job.fence.characterId || !Number.isSafeInteger(job.attempts) || job.attempts < 0 || !Number.isFinite(job.dueAt) || !Number.isFinite(job.createdAt) || !Number.isFinite(job.updatedAt)) throw new TypeError('wallet job is invalid');
|
||||
validateFence(job.fence);
|
||||
if (!['queued', 'running', 'succeeded', 'failed', 'cancelled', 'quarantined'].includes(job.status)) throw new TypeError('wallet job status is invalid');
|
||||
}
|
||||
|
||||
function claimMatches(job: WalletBalanceJob | null, claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now: number): job is WalletBalanceClaim {
|
||||
return !!job && job.status === 'running' && job.id === claim.id && job.workerId === claim.workerId && job.leaseToken === claim.leaseToken && job.leaseExpiresAt !== null && job.leaseExpiresAt > now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture-only bounded queue. A durable queue must serialize the same dedupe,
|
||||
* lease, cooldown and publication transitions instead of copying this Map.
|
||||
*/
|
||||
export class InMemoryWalletBalanceJobQueue implements WalletBalanceJobQueue {
|
||||
private readonly jobs = new Map<string, WalletBalanceJob>();
|
||||
private readonly activeByResource = new Map<string, string>();
|
||||
private readonly cooldowns = new Map<string, { until: number; group: string }>();
|
||||
private readonly maxPending: number;
|
||||
private readonly leaseMs: number;
|
||||
private readonly cooldownMs: number;
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(options: { maxPending?: number; leaseMs?: number; cooldownMs?: number; now?: () => number } = {}) {
|
||||
this.maxPending = options.maxPending ?? DEFAULT_WALLET_QUEUE_LIMIT;
|
||||
this.leaseMs = options.leaseMs ?? DEFAULT_WALLET_JOB_LEASE_MS;
|
||||
this.cooldownMs = options.cooldownMs ?? DEFAULT_WALLET_COOLDOWN_MS;
|
||||
this.clock = options.now ?? (() => Date.now());
|
||||
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1 || !Number.isSafeInteger(this.leaseMs) || this.leaseMs < 1 || !Number.isSafeInteger(this.cooldownMs) || this.cooldownMs < 0) throw new RangeError('wallet queue bounds are invalid');
|
||||
}
|
||||
|
||||
public async enqueue(request: WalletEnqueueRequest): Promise<WalletEnqueueResult> {
|
||||
validateFence(request.fence);
|
||||
if (typeof request.userId !== 'string' || !request.userId || request.userId !== request.fence.userId || request.characterId !== request.fence.characterId) throw new TypeError('wallet enqueue identity does not match fence');
|
||||
const now = validNow(request.now ?? this.now());
|
||||
const dueAt = request.dueAt === undefined ? now : validNow(request.dueAt);
|
||||
const key = `${request.userId}\u0000${request.characterId}`;
|
||||
const cooldown = this.cooldowns.get(key);
|
||||
if (cooldown && cooldown.until > now) return { status: 'cooldown', job: null, dueAt: cooldown.until };
|
||||
const activeId = this.activeByResource.get(key);
|
||||
if (activeId) {
|
||||
const active = this.jobs.get(activeId);
|
||||
if (active && (active.status === 'queued' || active.status === 'running')) return { status: 'duplicate', job: structuredClone(active) };
|
||||
this.activeByResource.delete(key);
|
||||
}
|
||||
const pending = [...this.jobs.values()].filter((job) => job.status === 'queued' || job.status === 'running').length;
|
||||
if (pending >= this.maxPending) return { status: 'queue_full', job: null, dueAt };
|
||||
const job: WalletBalanceJob = {
|
||||
id: randomUUID(), dataset: WALLET_BALANCE_DATASET, owner: { kind: 'user', userId: request.userId }, authority: 'user', characterId: request.characterId,
|
||||
fence: cloneFence(request.fence), status: 'queued', attempts: 0, dueAt, createdAt: now, updatedAt: now, leaseExpiresAt: null, workerId: null, leaseToken: null,
|
||||
};
|
||||
this.jobs.set(job.id, job);
|
||||
this.activeByResource.set(key, job.id);
|
||||
return { status: 'queued', job: structuredClone(job) };
|
||||
}
|
||||
|
||||
public now(): number { return validNow(this.clock()); }
|
||||
|
||||
public async claim(workerId: string, now = this.now()): Promise<WalletBalanceClaim | null> {
|
||||
if (typeof workerId !== 'string' || !workerId) throw new TypeError('wallet worker ID is invalid');
|
||||
const at = validNow(now);
|
||||
for (const job of this.jobs.values()) {
|
||||
if (job.status === 'running' && (job.leaseExpiresAt === null || job.leaseExpiresAt <= at)) {
|
||||
job.status = 'failed'; job.updatedAt = at; job.lastError = 'worker lease expired'; job.leaseExpiresAt = null; job.workerId = null; job.leaseToken = null; this.activeByResource.delete(`${job.owner.userId}\u0000${job.characterId}`);
|
||||
}
|
||||
}
|
||||
const candidate = [...this.jobs.values()].filter((job) => job.status === 'queued' && job.dueAt <= at).sort((a, b) => a.dueAt - b.dueAt || a.createdAt - b.createdAt)[0];
|
||||
if (!candidate) return null;
|
||||
candidate.status = 'running'; candidate.attempts += 1; candidate.updatedAt = at; candidate.workerId = workerId; candidate.leaseToken = randomUUID(); candidate.leaseExpiresAt = at + this.leaseMs;
|
||||
return structuredClone(candidate) as WalletBalanceClaim;
|
||||
}
|
||||
|
||||
public async renew(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now(), leaseMs = this.leaseMs): Promise<boolean> {
|
||||
const at = validNow(now);
|
||||
const job = this.jobs.get(claim.id) ?? null;
|
||||
if (!claimMatches(job, claim, at) || !Number.isSafeInteger(leaseMs) || leaseMs < 1) return false;
|
||||
job.leaseExpiresAt = at + leaseMs; job.updatedAt = at;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async complete(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, status: Exclude<WalletJobStatus, 'queued' | 'running' | 'succeeded'>, now = this.now(), error?: string): Promise<boolean> {
|
||||
const at = validNow(now);
|
||||
const job = this.jobs.get(claim.id) ?? null;
|
||||
if (!claimMatches(job, claim, at)) return false;
|
||||
const mutable: WalletBalanceJob = job;
|
||||
mutable.status = status; mutable.updatedAt = at; mutable.leaseExpiresAt = null; mutable.workerId = null; mutable.leaseToken = null; if (error) mutable.lastError = error;
|
||||
this.activeByResource.delete(`${mutable.owner.userId}\u0000${mutable.characterId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async defer(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, dueAt: number, now = this.now()): Promise<boolean> {
|
||||
const at = validNow(now); const target = validNow(dueAt); const job = this.jobs.get(claim.id) ?? null;
|
||||
if (!claimMatches(job, claim, at)) return false;
|
||||
const mutable: WalletBalanceJob = job;
|
||||
mutable.status = 'queued'; mutable.dueAt = target; mutable.updatedAt = at; mutable.leaseExpiresAt = null; mutable.workerId = null; mutable.leaseToken = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async isLive(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now()): Promise<boolean> {
|
||||
const at = validNow(now); return claimMatches(this.jobs.get(claim.id) ?? null, claim, at);
|
||||
}
|
||||
|
||||
public async cooldown(userId: string, characterId: number, now = this.now()): Promise<{ until: number; group: string } | null> {
|
||||
const at = validNow(now); const key = `${userId}\u0000${characterId}`; const value = this.cooldowns.get(key);
|
||||
if (!value || value.until <= at) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
/** Fixture/test hook: defer future automatic enqueues without changing auth. */
|
||||
public async setCooldown(userId: string, characterId: number, until: number, group = 'wallet'): Promise<void> {
|
||||
const value = validNow(until);
|
||||
this.cooldowns.set(`${userId}\u0000${characterId}`, { until: value, group });
|
||||
}
|
||||
|
||||
public async canonicalClaim(claim: Pick<WalletBalanceClaim, 'id' | 'workerId' | 'leaseToken'>, now = this.now()): Promise<WalletBalanceClaim | null> {
|
||||
const at = validNow(now);
|
||||
const job = this.jobs.get(claim.id) ?? null;
|
||||
if (!claimMatches(job, claim, at)) return null;
|
||||
validateJob(job);
|
||||
return structuredClone(job) as WalletBalanceClaim;
|
||||
}
|
||||
|
||||
public async get(jobId: string): Promise<WalletBalanceJob | null> {
|
||||
const job = this.jobs.get(jobId); return job ? structuredClone(job) : null;
|
||||
}
|
||||
|
||||
/** Called by the publication adapter after a successful remote read. */
|
||||
public async cooldownAfterSuccess(userId: string, characterId: number, now = this.now()): Promise<void> {
|
||||
if (this.cooldownMs > 0) await this.setCooldown(userId, characterId, validNow(now) + this.cooldownMs, 'wallet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture publication adapter: it composes the queue lease fence with the
|
||||
* repository's atomic authority/publication methods. Durable implementations
|
||||
* should perform this transition in one serialized checkpoint.
|
||||
*/
|
||||
export class InMemoryWalletBalancePublicationPort implements WalletBalancePublicationPort {
|
||||
public constructor(private readonly jobs: WalletBalanceJobQueue, private readonly repository: WalletBalanceRepository) {}
|
||||
|
||||
public async publishSuccess(input: WalletSnapshotPublication): Promise<WalletPublicationResult> {
|
||||
try { validateWalletAuthorityFence(input.fence); } catch { return { accepted: false, reason: 'borrowed_resource' }; }
|
||||
const serverNow = this.jobs.now();
|
||||
const claim = await this.jobs.canonicalClaim({ id: input.jobId, workerId: input.workerId, leaseToken: input.leaseToken }, serverNow);
|
||||
if (!claim) return { accepted: false, reason: 'expired_job' };
|
||||
if (claim.characterId !== input.fence.characterId || claim.owner.userId !== input.fence.userId || !sameWalletFence(claim.fence, input.fence)) return { accepted: false, reason: 'borrowed_resource' };
|
||||
const publication = { ...input, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, publishedAt: serverNow };
|
||||
let result: WalletPublicationResult;
|
||||
try { result = await this.repository.publishSnapshot(publication); validatePublicationResult(result, 'success', publication); } catch { await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, 'malformed_publication'); return { accepted: false, reason: 'invalid_snapshot' }; }
|
||||
if (result.accepted) {
|
||||
await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'succeeded', serverNow);
|
||||
await this.jobs.cooldownAfterSuccess(claim.owner.userId, claim.characterId, serverNow);
|
||||
} else await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async publishFailure(input: WalletFailurePublication): Promise<WalletPublicationResult> {
|
||||
try { validateWalletAuthorityFence(input.fence); } catch { return { accepted: false, reason: 'borrowed_resource' }; }
|
||||
const serverNow = this.jobs.now();
|
||||
const claim = await this.jobs.canonicalClaim({ id: input.jobId, workerId: input.workerId, leaseToken: input.leaseToken }, serverNow);
|
||||
if (!claim) return { accepted: false, reason: 'expired_job' };
|
||||
if (claim.characterId !== input.fence.characterId || claim.owner.userId !== input.fence.userId || !sameWalletFence(claim.fence, input.fence)) return { accepted: false, reason: 'borrowed_resource' };
|
||||
const publication = { ...input, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, publishedAt: serverNow };
|
||||
let result: WalletPublicationResult;
|
||||
try { result = await this.repository.publishFailure(publication); validatePublicationResult(result, 'failure', publication); } catch { await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, 'malformed_publication'); return { accepted: false, reason: 'invalid_snapshot' }; }
|
||||
await this.jobs.complete({ id: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken }, 'failed', serverNow, input.failure.category);
|
||||
if (result.accepted && input.failure.retryAt !== undefined) await this.jobs.setCooldown(claim.owner.userId, claim.characterId, input.failure.retryAt, input.failure.cooldownGroup ?? 'wallet');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function validateObservation(observation: EsiWalletBalanceObservation, now: number): void {
|
||||
if (!observation || typeof observation !== 'object') throw new TypeError('wallet transport response is invalid');
|
||||
validateWalletAmount(observation.amount);
|
||||
if (!Number.isFinite(observation.retrievedAt) || observation.retrievedAt < 0 || observation.retrievedAt > now || !Number.isFinite(observation.expiresAt) || observation.expiresAt <= observation.retrievedAt) throw new TypeError('wallet transport timestamps are invalid');
|
||||
// A transport cannot claim a negative/unknown expiry, but upstream effective
|
||||
// time remains null because ESI does not provide one for this endpoint.
|
||||
if (observation.expiresAt <= now) throw new TypeError('wallet transport returned an already expired observation');
|
||||
}
|
||||
|
||||
function exactScopes(left: unknown, right: readonly string[]): boolean {
|
||||
if (!Array.isArray(left) || left.length !== right.length || left.length === 0 || left.length > 64 || left.some((scope) => typeof scope !== 'string' || !scope || scope.length > 256) || new Set(left).size !== left.length) return false;
|
||||
return [...left].sort().join('\u0000') === [...right].sort().join('\u0000');
|
||||
}
|
||||
|
||||
function validCredential(value: WalletCredential | null, fence: WalletAuthorityFence): value is WalletCredential {
|
||||
if (!value || typeof value !== 'object' || typeof value.accessToken !== 'string' || !value.accessToken || !value.authorizationKey || typeof value.authorizationKey !== 'object') return false;
|
||||
const key = value.authorizationKey;
|
||||
return key.userId === fence.userId && key.principal === `character:${fence.principal.id}` && key.characterId === fence.characterId && key.grantRevision === fence.grantRevision && key.authorizationEpoch === fence.authorizationEpoch && key.credentialVersion === fence.credentialVersion && exactScopes(key.scopes, fence.scopes);
|
||||
}
|
||||
|
||||
const PUBLICATION_REASONS = new Set(['borrowed_resource', 'stale_grant', 'stale_credential', 'stale_authorization_epoch', 'authorization_revoked', 'mismatched_character', 'invalid_snapshot', 'stale_generation', 'expired_job', 'duplicate_publication']);
|
||||
function samePublishedSnapshot(left: WalletBalanceSnapshot, right: WalletBalanceSnapshot): boolean {
|
||||
return left.userId === right.userId && left.characterId === right.characterId && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.amount === right.amount && left.fetchedAt === right.fetchedAt && left.expiresAt === right.expiresAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt && left.generation === right.generation && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion;
|
||||
}
|
||||
function validatePublicationResult(value: WalletPublicationResult, expected: 'success' | 'failure', requested: WalletSnapshotPublication | WalletFailurePublication): void {
|
||||
if (!value || typeof value !== 'object' || typeof value.accepted !== 'boolean') throw new TypeError('wallet publication returned malformed result');
|
||||
if (value.accepted) {
|
||||
if (value.outcome !== expected) throw new TypeError('wallet publication outcome does not match request');
|
||||
if (expected === 'success') {
|
||||
if (value.snapshot === null) throw new TypeError('successful wallet publication omitted snapshot');
|
||||
validateWalletSnapshot(value.snapshot);
|
||||
const input = requested as WalletSnapshotPublication;
|
||||
if (value.snapshot.userId !== input.fence.userId || value.snapshot.characterId !== input.fence.characterId || value.snapshot.principal.id !== input.fence.principal.id || value.snapshot.authorizationRevision !== input.fence.grantRevision || value.snapshot.authorizationEpoch !== input.fence.authorizationEpoch || value.snapshot.credentialVersion !== input.fence.credentialVersion || !samePublishedSnapshot(value.snapshot, input.snapshot)) throw new TypeError('successful wallet publication snapshot does not match request');
|
||||
} else if (value.snapshot !== null) throw new TypeError('failure wallet publication returned a snapshot');
|
||||
} else if (!PUBLICATION_REASONS.has(value.reason)) throw new TypeError('wallet publication rejection is invalid');
|
||||
}
|
||||
|
||||
function failureDetails(error: unknown): { category: string; message: string; retryable: boolean; retryAt?: number; cooldownGroup?: string } {
|
||||
if (error instanceof EsiError) {
|
||||
const cooldown = 'cooldown' in error && error.cooldown && typeof error.cooldown === 'object' ? error.cooldown as { until?: unknown; group?: unknown; context?: unknown } : undefined;
|
||||
const cooldownGroup = typeof cooldown?.group === 'string' ? cooldown.group : typeof cooldown?.context === 'string' ? cooldown.context : undefined;
|
||||
return { category: error.code, message: error.message, retryable: error.code !== 'authorization_context', ...(typeof cooldown?.until === 'number' && Number.isFinite(cooldown.until) ? { retryAt: cooldown.until } : {}), ...(cooldownGroup === undefined ? {} : { cooldownGroup }) };
|
||||
}
|
||||
if (error instanceof Error) return { category: 'refresh_failed', message: 'wallet refresh failed', retryable: true };
|
||||
return { category: 'refresh_failed', message: 'wallet refresh failed', retryable: true };
|
||||
}
|
||||
|
||||
/** Runs one claimed job and never broadens its OAuth grants. */
|
||||
export class WalletBalanceJobAdapter {
|
||||
private readonly now: () => number;
|
||||
private readonly leaseMs: number;
|
||||
private readonly heartbeatMs: number;
|
||||
|
||||
public constructor(private readonly options: WalletBalanceJobAdapterOptions) {
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.leaseMs = options.leaseMs ?? DEFAULT_WALLET_JOB_LEASE_MS;
|
||||
this.heartbeatMs = options.heartbeatMs ?? Math.max(10, Math.floor(this.leaseMs / 3));
|
||||
if (!Number.isSafeInteger(this.leaseMs) || this.leaseMs < 1 || !Number.isSafeInteger(this.heartbeatMs) || this.heartbeatMs < 1 || this.heartbeatMs >= this.leaseMs) throw new RangeError('wallet job lease/heartbeat is invalid');
|
||||
}
|
||||
|
||||
/** Keep remote I/O inside the current worker lease; a lost lease aborts and forbids publication. */
|
||||
private async withLease<T>(claim: WalletBalanceClaim, work: (signal: AbortSignal) => Promise<T>, checkAfter = true): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let lost = false;
|
||||
const renew = async (): Promise<void> => {
|
||||
try {
|
||||
const renewed = await this.options.jobs.renew(claim, this.options.jobs.now(), this.leaseMs);
|
||||
if (!renewed) {
|
||||
lost = true;
|
||||
controller.abort();
|
||||
}
|
||||
} catch {
|
||||
lost = true;
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
await renew();
|
||||
if (lost) throw new WalletLeaseLostError();
|
||||
const timer = setInterval(() => { void renew(); }, this.heartbeatMs);
|
||||
try {
|
||||
const result = await work(controller.signal);
|
||||
if (lost || (checkAfter && !(await this.options.jobs.isLive(claim, this.options.jobs.now())))) throw new WalletLeaseLostError();
|
||||
return result;
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async unavailable(claim: WalletBalanceClaim, error?: unknown): Promise<WalletJobRunResult> {
|
||||
const at = this.options.jobs.now();
|
||||
const message = error instanceof WalletAuthorityUnavailableError ? error.message : 'wallet authority backend is unavailable';
|
||||
try {
|
||||
await this.options.jobs.defer(claim, at + Math.min(this.leaseMs, 5_000), at);
|
||||
} catch {
|
||||
// The original unavailable classification remains safer than quarantining
|
||||
// a resource when the queue backend itself cannot be reached.
|
||||
}
|
||||
return { status: 'unavailable', job: claim, error: message };
|
||||
}
|
||||
|
||||
private async publishRefreshFailure(claim: WalletBalanceClaim, error: unknown): Promise<WalletJobRunResult> {
|
||||
if (error instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
|
||||
const details = failureDetails(error);
|
||||
const failedAt = this.options.jobs.now();
|
||||
const failure: WalletFailurePublication = {
|
||||
jobId: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken,
|
||||
fence: claim.fence,
|
||||
publishedAt: failedAt,
|
||||
failure: { userId: claim.owner.userId, characterId: claim.characterId, failedAt, category: details.category, message: details.message, retryable: details.retryable, authorizationRevision: claim.fence.grantRevision, authorizationEpoch: claim.fence.authorizationEpoch, credentialVersion: claim.fence.credentialVersion, generation: `wallet-${claim.id}-${claim.attempts}`, ...(details.retryAt === undefined ? {} : { retryAt: details.retryAt }), ...(details.cooldownGroup === undefined ? {} : { cooldownGroup: details.cooldownGroup }), jobId: claim.id },
|
||||
};
|
||||
try {
|
||||
const publication = await this.withLease(claim, async () => this.options.publication.publishFailure(failure), false);
|
||||
validatePublicationResult(publication, 'failure', failure);
|
||||
if (!publication.accepted && publication.reason === 'expired_job') return { status: 'rejected', job: claim, reason: publication.reason };
|
||||
return { status: 'failed', job: claim, error: details.message };
|
||||
} catch (publicationError) {
|
||||
if (publicationError instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
|
||||
return this.unavailable(claim, publicationError);
|
||||
}
|
||||
}
|
||||
|
||||
public async runNext(workerId: string): Promise<WalletJobRunResult> {
|
||||
const at = this.options.jobs.now();
|
||||
const claim = await this.options.jobs.claim(workerId, at);
|
||||
if (!claim) return { status: 'idle' };
|
||||
return this.runClaim(claim);
|
||||
}
|
||||
|
||||
public async runClaim(claim: WalletBalanceClaim): Promise<WalletJobRunResult> {
|
||||
const startedAt = this.options.jobs.now();
|
||||
let canonical: WalletBalanceClaim | null;
|
||||
try {
|
||||
canonical = await this.options.jobs.canonicalClaim(claim, startedAt);
|
||||
} catch {
|
||||
return { status: 'unavailable', job: claim, error: 'wallet job authority is unavailable' };
|
||||
}
|
||||
if (!canonical) return { status: 'rejected', job: claim, reason: 'expired_job' };
|
||||
try { validateJob(canonical); } catch { return this.unavailable(claim, new WalletAuthorityUnavailableError('wallet job response is malformed')); }
|
||||
// Never use mutable caller-owned owner/character/fence fields after this point.
|
||||
claim = canonical;
|
||||
let current: WalletAuthorityFence | null;
|
||||
try {
|
||||
current = await this.options.authority.currentAuthority(claim.owner.userId, claim.characterId);
|
||||
} catch (error) {
|
||||
// A failed authority read is not evidence of revocation. Leave the job
|
||||
// queued for a bounded retry instead of quarantining its resource.
|
||||
return this.unavailable(claim, error);
|
||||
}
|
||||
if (current) {
|
||||
try { validateWalletAuthorityFence(current); } catch (error) { return this.unavailable(claim, new WalletAuthorityUnavailableError('wallet authority response is malformed')); }
|
||||
}
|
||||
if (!current || !sameWalletFence(current, claim.fence)) {
|
||||
const reason = !current ? 'authorization_revoked' : current.grantRevision !== claim.fence.grantRevision ? 'stale_grant' : current.authorizationEpoch !== claim.fence.authorizationEpoch ? 'stale_authorization_epoch' : current.credentialVersion !== claim.fence.credentialVersion ? 'stale_credential' : 'borrowed_resource';
|
||||
await this.options.jobs.complete(claim, 'quarantined', startedAt, reason);
|
||||
return { status: 'rejected', job: claim, reason };
|
||||
}
|
||||
let credential: WalletCredential | null;
|
||||
try {
|
||||
credential = await this.options.authority.credential(claim.fence);
|
||||
} catch (error) {
|
||||
return this.unavailable(claim, error);
|
||||
}
|
||||
if (!validCredential(credential, claim.fence)) {
|
||||
await this.options.jobs.complete(claim, 'quarantined', startedAt, 'credential_authority_mismatch');
|
||||
return { status: 'rejected', job: claim, reason: 'credential_authority_mismatch' };
|
||||
}
|
||||
|
||||
let observation: EsiWalletBalanceObservation;
|
||||
try {
|
||||
observation = await this.withLease(claim, (signal) => this.options.transport.getWalletBalance(claim.characterId, credential.accessToken, credential.authorizationKey, signal));
|
||||
} catch (error) {
|
||||
return this.publishRefreshFailure(claim, error);
|
||||
}
|
||||
const observedAt = this.options.jobs.now();
|
||||
try {
|
||||
validateObservation(observation, observedAt);
|
||||
} catch (error) {
|
||||
return this.publishRefreshFailure(claim, error);
|
||||
}
|
||||
const generation = `wallet-${claim.id}-${claim.attempts}`;
|
||||
const snapshot: WalletBalanceSnapshot = {
|
||||
userId: claim.owner.userId,
|
||||
characterId: claim.characterId,
|
||||
principal: { ...claim.fence.principal },
|
||||
amount: observation.amount,
|
||||
fetchedAt: observation.retrievedAt,
|
||||
expiresAt: observation.expiresAt,
|
||||
upstreamEffectiveAt: null,
|
||||
coverage: { kind: 'snapshot', generation, complete: true },
|
||||
generation,
|
||||
authorizationRevision: claim.fence.grantRevision,
|
||||
authorizationEpoch: claim.fence.authorizationEpoch,
|
||||
credentialVersion: claim.fence.credentialVersion,
|
||||
};
|
||||
const publication: WalletSnapshotPublication = { jobId: claim.id, workerId: claim.workerId, leaseToken: claim.leaseToken, fence: claim.fence, snapshot, publishedAt: observedAt };
|
||||
try {
|
||||
const result = await this.withLease(claim, () => this.options.publication.publishSuccess({ ...publication, publishedAt: validNow(this.now()) }), false);
|
||||
validatePublicationResult(result, 'success', { ...publication, publishedAt: validNow(this.now()) });
|
||||
if (!result.accepted) return { status: 'rejected', job: claim, reason: result.reason };
|
||||
return { status: 'published', job: claim, publication: result };
|
||||
} catch (error) {
|
||||
if (error instanceof WalletLeaseLostError) return { status: 'rejected', job: claim, reason: 'lease_lost' };
|
||||
return this.unavailable(claim, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type WalletBalanceJobHandler = WalletBalanceJobAdapter;
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { AuthorizationKey } from '../esi';
|
||||
import { ESI_WALLET_SCOPE } from '../esi';
|
||||
import type {
|
||||
AuthorizationStatus,
|
||||
CompleteReportProvenance,
|
||||
DatasetCoverage,
|
||||
ResourcePrincipal,
|
||||
SelectedResource,
|
||||
WalletBalanceComponent,
|
||||
} from '../../domain/model';
|
||||
import { isDecimalId } from '../../domain/inventory-location';
|
||||
import { parseIsk } from '../../domain/money';
|
||||
|
||||
/** A character resource resolved by the server, never by a browser selector. */
|
||||
export type WalletAuthorizedResource = {
|
||||
userId: string;
|
||||
characterId: number;
|
||||
characterName?: string;
|
||||
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
|
||||
resource: SelectedResource;
|
||||
authorizationStatus: AuthorizationStatus;
|
||||
consent: 'granted' | 'missing';
|
||||
grantedScopes: readonly string[];
|
||||
grantRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number | null;
|
||||
};
|
||||
|
||||
/** The immutable wallet snapshot kept independently of authorization metadata. */
|
||||
export type WalletBalanceSnapshot = {
|
||||
userId: string;
|
||||
characterId: number;
|
||||
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
|
||||
amount: string;
|
||||
fetchedAt: number;
|
||||
expiresAt: number;
|
||||
upstreamEffectiveAt: number | null;
|
||||
coverage: Extract<DatasetCoverage, { kind: 'snapshot' }>;
|
||||
authorizationRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number;
|
||||
generation: string;
|
||||
};
|
||||
|
||||
export type WalletRefreshFailure = {
|
||||
userId: string;
|
||||
characterId: number;
|
||||
failedAt: number;
|
||||
category: string;
|
||||
retryable: boolean;
|
||||
authorizationRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number;
|
||||
/** Monotonic job attempt identity; content cannot mutate under one generation. */
|
||||
generation: string;
|
||||
retryAt?: number;
|
||||
cooldownGroup?: string;
|
||||
message?: string;
|
||||
jobId?: string;
|
||||
};
|
||||
|
||||
/** The complete authority fence copied into a job and checked at publication. */
|
||||
export type WalletAuthorityFence = {
|
||||
userId: string;
|
||||
characterId: number;
|
||||
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
|
||||
grantRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number;
|
||||
scopes: readonly string[];
|
||||
};
|
||||
|
||||
export type WalletCredential = {
|
||||
accessToken: string;
|
||||
authorizationKey: AuthorizationKey;
|
||||
};
|
||||
|
||||
export type WalletSnapshotPublication = {
|
||||
jobId: string;
|
||||
workerId: string;
|
||||
leaseToken: string;
|
||||
fence: WalletAuthorityFence;
|
||||
snapshot: WalletBalanceSnapshot;
|
||||
publishedAt: number;
|
||||
};
|
||||
|
||||
export type WalletFailurePublication = {
|
||||
jobId: string;
|
||||
workerId: string;
|
||||
leaseToken: string;
|
||||
fence: WalletAuthorityFence;
|
||||
failure: WalletRefreshFailure;
|
||||
publishedAt: number;
|
||||
};
|
||||
|
||||
export type WalletPublicationRejection =
|
||||
| 'borrowed_resource'
|
||||
| 'stale_grant'
|
||||
| 'stale_credential'
|
||||
| 'stale_authorization_epoch'
|
||||
| 'authorization_revoked'
|
||||
| 'mismatched_character'
|
||||
| 'invalid_snapshot'
|
||||
| 'stale_generation'
|
||||
| 'expired_job'
|
||||
| 'duplicate_publication';
|
||||
|
||||
export type WalletPublicationResult =
|
||||
| { accepted: true; snapshot: WalletBalanceSnapshot | null; outcome: 'success' | 'failure' }
|
||||
| { accepted: false; reason: WalletPublicationRejection };
|
||||
|
||||
/**
|
||||
* Persistence/runtime seam owned by a later serialized checkpoint. Implementations
|
||||
* must make authority checks and snapshot publication atomic. No OAuth behavior is
|
||||
* defined here; credential() only reads an already-authorized credential.
|
||||
*/
|
||||
export interface WalletBalanceRepository {
|
||||
authorizedResources(userId: string): Promise<readonly WalletAuthorizedResource[]>;
|
||||
snapshot(userId: string, characterId: number): Promise<WalletBalanceSnapshot | null>;
|
||||
failure(userId: string, characterId: number): Promise<WalletRefreshFailure | null>;
|
||||
currentAuthority(userId: string, characterId: number): Promise<WalletAuthorityFence | null>;
|
||||
credential(fence: WalletAuthorityFence): Promise<WalletCredential | null>;
|
||||
publishSnapshot(publication: WalletSnapshotPublication): Promise<WalletPublicationResult>;
|
||||
publishFailure(publication: WalletFailurePublication): Promise<WalletPublicationResult>;
|
||||
}
|
||||
|
||||
/** Compatibility aliases make the feature-local port easy to adopt without changing shared contracts. */
|
||||
export type WalletRepository = WalletBalanceRepository;
|
||||
export type WalletResource = WalletAuthorizedResource;
|
||||
|
||||
export function walletResourceKey(userId: string, characterId: number): string {
|
||||
return `${userId}\u0000${characterId}`;
|
||||
}
|
||||
|
||||
export function walletPrincipalKey(principal: ResourcePrincipal): string {
|
||||
return `${principal.kind}:${principal.id}`;
|
||||
}
|
||||
|
||||
export const MAX_WALLET_AMOUNT_LENGTH = 8_192;
|
||||
export const MAX_WALLET_AUTH_SCOPES = 64;
|
||||
|
||||
function validCounter(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function clonePrincipal(principal: WalletAuthorizedResource['principal']): WalletAuthorizedResource['principal'] {
|
||||
return { ...principal };
|
||||
}
|
||||
|
||||
function cloneResource(resource: WalletAuthorizedResource): WalletAuthorizedResource {
|
||||
return {
|
||||
...resource,
|
||||
principal: clonePrincipal(resource.principal),
|
||||
resource: { ...resource.resource, principal: { ...resource.resource.principal }, requiredScopes: [...resource.resource.requiredScopes] },
|
||||
grantedScopes: [...resource.grantedScopes],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: WalletBalanceSnapshot): WalletBalanceSnapshot {
|
||||
return { ...snapshot, principal: clonePrincipal(snapshot.principal), coverage: { ...snapshot.coverage } };
|
||||
}
|
||||
|
||||
function cloneFence(fence: WalletAuthorityFence): WalletAuthorityFence {
|
||||
return { ...fence, principal: clonePrincipal(fence.principal), scopes: [...fence.scopes] };
|
||||
}
|
||||
|
||||
function cloneFailure(failure: WalletRefreshFailure): WalletRefreshFailure {
|
||||
return { ...failure };
|
||||
}
|
||||
|
||||
function canonicalScopes(value: unknown, allowEmpty = false): string[] | null {
|
||||
if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > MAX_WALLET_AUTH_SCOPES) return null;
|
||||
if (value.some((scope) => typeof scope !== 'string' || scope.length === 0 || scope.length > 256)) return null;
|
||||
const scopes = [...value] as string[];
|
||||
if (new Set(scopes).size !== scopes.length) return null;
|
||||
return scopes.sort();
|
||||
}
|
||||
|
||||
export function validateWalletScopes(value: unknown, allowEmpty = false): readonly string[] {
|
||||
const scopes = canonicalScopes(value, allowEmpty);
|
||||
if (!scopes) throw new TypeError('wallet scopes are invalid');
|
||||
return scopes;
|
||||
}
|
||||
|
||||
export function validateWalletAuthorityFence(fence: WalletAuthorityFence): void {
|
||||
if (!fence || typeof fence.userId !== 'string' || !fence.userId || !Number.isSafeInteger(fence.characterId) || fence.characterId <= 0 || !fence.principal || fence.principal.kind !== 'character' || !isDecimalId(fence.principal.id) || fence.principal.id !== String(fence.characterId) || fence.principal.ownerUserId !== fence.userId || !validCounter(fence.grantRevision) || !validCounter(fence.authorizationEpoch) || !validCounter(fence.credentialVersion) || !canonicalScopes(fence.scopes)?.includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet authority fence is invalid');
|
||||
}
|
||||
|
||||
export function validateWalletAmount(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_WALLET_AMOUNT_LENGTH) throw new RangeError('wallet amount exceeds resource bounds');
|
||||
const parsed = parseIsk(value);
|
||||
const canonical = parsed.toString(10);
|
||||
if (value !== canonical) throw new TypeError('wallet amount is not canonical signed integer cents');
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function assertResource(resource: WalletAuthorizedResource): void {
|
||||
if (!resource || typeof resource.userId !== 'string' || !resource.userId || !Number.isSafeInteger(resource.characterId) || resource.characterId <= 0) throw new TypeError('wallet resource identity is invalid');
|
||||
if (!isDecimalId(resource.principal.id) || resource.principal.ownerUserId !== resource.userId || resource.principal.kind !== 'character' || resource.principal.id !== String(resource.characterId)) throw new TypeError('wallet resource principal is invalid');
|
||||
if (!resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.id !== resource.principal.id || resource.resource.principal.ownerUserId !== resource.userId) throw new TypeError('wallet resource selector is invalid');
|
||||
if (!Array.isArray(resource.resource.requiredScopes) || !canonicalScopes(resource.resource.requiredScopes)?.includes(ESI_WALLET_SCOPE) || resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('wallet resource scope pin is invalid');
|
||||
if (!canonicalScopes(resource.grantedScopes, true)) throw new TypeError('wallet granted scopes are invalid');
|
||||
if (!validCounter(resource.grantRevision) || !validCounter(resource.authorizationEpoch) || (resource.credentialVersion !== null && !validCounter(resource.credentialVersion))) throw new TypeError('wallet authority counters are invalid');
|
||||
if (!['granted', 'missing'].includes(resource.consent)) throw new TypeError('wallet consent state is invalid');
|
||||
if (!['active', 'revocation_pending', 'quarantined', 'revoked', 'transferred'].includes(resource.authorizationStatus)) throw new TypeError('wallet authorization state is invalid');
|
||||
if (resource.consent === 'granted' && !resource.grantedScopes.includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet granted consent lacks the required scope');
|
||||
if (resource.consent === 'granted' && resource.credentialVersion === null) throw new TypeError('wallet granted consent lacks credential version');
|
||||
}
|
||||
|
||||
export function walletFenceForResource(resource: WalletAuthorizedResource): WalletAuthorityFence | null {
|
||||
assertResource(resource);
|
||||
if (resource.consent !== 'granted' || resource.authorizationStatus !== 'active' || resource.credentialVersion === null) return null;
|
||||
return {
|
||||
userId: resource.userId,
|
||||
characterId: resource.characterId,
|
||||
principal: clonePrincipal(resource.principal),
|
||||
grantRevision: resource.grantRevision,
|
||||
authorizationEpoch: resource.authorizationEpoch,
|
||||
credentialVersion: resource.credentialVersion,
|
||||
scopes: [...resource.grantedScopes].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameWalletFence(left: WalletAuthorityFence, right: WalletAuthorityFence): boolean {
|
||||
return left.userId === right.userId
|
||||
&& left.characterId === right.characterId
|
||||
&& left.principal.kind === 'character'
|
||||
&& right.principal.kind === 'character'
|
||||
&& left.principal.id === right.principal.id
|
||||
&& left.principal.ownerUserId === right.principal.ownerUserId
|
||||
&& left.grantRevision === right.grantRevision
|
||||
&& left.authorizationEpoch === right.authorizationEpoch
|
||||
&& left.credentialVersion === right.credentialVersion
|
||||
&& [...left.scopes].sort().join('\u0000') === [...right.scopes].sort().join('\u0000');
|
||||
}
|
||||
|
||||
export function validateWalletSnapshot(snapshot: WalletBalanceSnapshot): void {
|
||||
if (!snapshot || typeof snapshot.userId !== 'string' || !snapshot.userId || !Number.isSafeInteger(snapshot.characterId) || snapshot.characterId <= 0) throw new TypeError('wallet snapshot identity is invalid');
|
||||
if (snapshot.principal.kind !== 'character' || snapshot.principal.ownerUserId !== snapshot.userId || snapshot.principal.id !== String(snapshot.characterId)) throw new TypeError('wallet snapshot principal is invalid');
|
||||
validateWalletAmount(snapshot.amount);
|
||||
if (!Number.isFinite(snapshot.fetchedAt) || snapshot.fetchedAt < 0 || !Number.isFinite(snapshot.expiresAt) || snapshot.expiresAt <= snapshot.fetchedAt) throw new TypeError('wallet snapshot timestamps are invalid');
|
||||
if (snapshot.upstreamEffectiveAt !== null && (!Number.isFinite(snapshot.upstreamEffectiveAt) || snapshot.upstreamEffectiveAt < 0)) throw new TypeError('wallet upstream timestamp is invalid');
|
||||
if (snapshot.coverage.kind !== 'snapshot' || snapshot.coverage.complete !== true || typeof snapshot.coverage.generation !== 'string' || !snapshot.coverage.generation) throw new TypeError('wallet snapshot coverage is invalid');
|
||||
if (snapshot.generation !== snapshot.coverage.generation || !snapshot.generation || snapshot.generation.length > MAX_WALLET_AMOUNT_LENGTH) throw new TypeError('wallet snapshot generation is invalid');
|
||||
if (!validCounter(snapshot.authorizationRevision) || !validCounter(snapshot.authorizationEpoch) || !validCounter(snapshot.credentialVersion)) throw new TypeError('wallet snapshot authority counters are invalid');
|
||||
}
|
||||
|
||||
export function validateWalletRefreshFailure(failure: WalletRefreshFailure): void {
|
||||
if (!failure || typeof failure.userId !== 'string' || !failure.userId || !Number.isSafeInteger(failure.characterId) || failure.characterId <= 0 || !Number.isFinite(failure.failedAt) || failure.failedAt < 0 || typeof failure.category !== 'string' || !failure.category || typeof failure.retryable !== 'boolean' || !validCounter(failure.authorizationRevision) || !validCounter(failure.authorizationEpoch) || !validCounter(failure.credentialVersion) || typeof failure.generation !== 'string' || !failure.generation || failure.generation.length > MAX_WALLET_AMOUNT_LENGTH || (failure.retryAt !== undefined && (!Number.isFinite(failure.retryAt) || failure.retryAt < failure.failedAt)) || (failure.cooldownGroup !== undefined && (typeof failure.cooldownGroup !== 'string' || !failure.cooldownGroup))) throw new TypeError('wallet refresh failure is invalid');
|
||||
}
|
||||
|
||||
const validateFailure = validateWalletRefreshFailure;
|
||||
|
||||
function sameSnapshot(left: WalletBalanceSnapshot, right: WalletBalanceSnapshot): boolean {
|
||||
return left.userId === right.userId && left.characterId === right.characterId && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.amount === right.amount && left.fetchedAt === right.fetchedAt && left.expiresAt === right.expiresAt && left.upstreamEffectiveAt === right.upstreamEffectiveAt && left.generation === right.generation && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion;
|
||||
}
|
||||
|
||||
function sameFailure(left: WalletRefreshFailure, right: WalletRefreshFailure): boolean {
|
||||
return left.userId === right.userId && left.characterId === right.characterId && left.failedAt === right.failedAt && left.category === right.category && left.retryable === right.retryable && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && left.credentialVersion === right.credentialVersion && left.generation === right.generation && left.retryAt === right.retryAt && left.cooldownGroup === right.cooldownGroup && left.message === right.message && left.jobId === right.jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture-only memory adapter. It intentionally contains no Convex/schema or
|
||||
* production queue behavior; a durable adapter must preserve these fences and
|
||||
* checkpoint the stage/publication transition atomically.
|
||||
*/
|
||||
export class InMemoryWalletBalanceRepository implements WalletBalanceRepository {
|
||||
private readonly resources = new Map<string, WalletAuthorizedResource>();
|
||||
private readonly snapshots = new Map<string, WalletBalanceSnapshot>();
|
||||
private readonly failures = new Map<string, WalletRefreshFailure>();
|
||||
private readonly credentials = new Map<string, WalletCredential>();
|
||||
private readonly publishedJobs = new Map<string, { fence: WalletAuthorityFence; outcome: 'success'; snapshot: WalletBalanceSnapshot } | { fence: WalletAuthorityFence; outcome: 'failure'; failure: WalletRefreshFailure }>();
|
||||
|
||||
public constructor(initial: {
|
||||
resources?: readonly WalletAuthorizedResource[];
|
||||
snapshots?: readonly WalletBalanceSnapshot[];
|
||||
failures?: readonly WalletRefreshFailure[];
|
||||
credentials?: readonly { fence: WalletAuthorityFence; credential: WalletCredential }[];
|
||||
} = {}) {
|
||||
for (const resource of initial.resources ?? []) this.seedResource(resource);
|
||||
for (const snapshot of initial.snapshots ?? []) this.seedSnapshot(snapshot);
|
||||
for (const failure of initial.failures ?? []) this.seedFailure(failure);
|
||||
for (const entry of initial.credentials ?? []) this.seedCredential(entry.fence, entry.credential);
|
||||
}
|
||||
|
||||
public seedResource(resource: WalletAuthorizedResource): void {
|
||||
assertResource(resource);
|
||||
const key = walletResourceKey(resource.userId, resource.characterId);
|
||||
if (this.resources.has(key)) throw new TypeError('duplicate wallet resource');
|
||||
this.resources.set(key, cloneResource(resource));
|
||||
}
|
||||
|
||||
/** Fixture-only authority rotation hook for stale-worker tests. */
|
||||
public replaceResource(resource: WalletAuthorizedResource): void {
|
||||
assertResource(resource);
|
||||
this.resources.set(walletResourceKey(resource.userId, resource.characterId), cloneResource(resource));
|
||||
}
|
||||
|
||||
public seedSnapshot(snapshot: WalletBalanceSnapshot): void {
|
||||
validateWalletSnapshot(snapshot);
|
||||
this.snapshots.set(walletResourceKey(snapshot.userId, snapshot.characterId), cloneSnapshot(snapshot));
|
||||
}
|
||||
|
||||
public seedFailure(failure: WalletRefreshFailure): void {
|
||||
validateFailure(failure);
|
||||
this.failures.set(walletResourceKey(failure.userId, failure.characterId), cloneFailure(failure));
|
||||
}
|
||||
|
||||
public seedCredential(fence: WalletAuthorityFence, credential: WalletCredential): void {
|
||||
validateWalletAuthorityFence(fence);
|
||||
if (typeof credential.accessToken !== 'string' || !credential.accessToken) throw new TypeError('wallet credential is invalid');
|
||||
const key = credential.authorizationKey;
|
||||
if (!key || key.userId !== fence.userId || key.principal !== walletPrincipalKey(fence.principal) || key.characterId !== fence.characterId || key.grantRevision !== fence.grantRevision || key.authorizationEpoch !== fence.authorizationEpoch || key.credentialVersion !== fence.credentialVersion || canonicalScopes(key.scopes)?.join('\u0000') !== canonicalScopes(fence.scopes)?.join('\u0000')) throw new TypeError('wallet credential authority does not match');
|
||||
this.credentials.set(walletResourceKey(fence.userId, fence.characterId), { accessToken: credential.accessToken, authorizationKey: { ...key, scopes: [...key.scopes] } });
|
||||
}
|
||||
|
||||
public async authorizedResources(userId: string): Promise<readonly WalletAuthorizedResource[]> {
|
||||
if (typeof userId !== 'string' || !userId) throw new TypeError('wallet user is invalid');
|
||||
return [...this.resources.values()].filter((resource) => resource.userId === userId).sort((a, b) => a.characterId - b.characterId).map(cloneResource);
|
||||
}
|
||||
|
||||
public async snapshot(userId: string, characterId: number): Promise<WalletBalanceSnapshot | null> {
|
||||
if (typeof userId !== 'string' || !userId || !Number.isSafeInteger(characterId) || characterId <= 0) throw new TypeError('wallet snapshot lookup is invalid');
|
||||
const snapshot = this.snapshots.get(walletResourceKey(userId, characterId));
|
||||
return snapshot ? cloneSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
public async failure(userId: string, characterId: number): Promise<WalletRefreshFailure | null> {
|
||||
const failure = this.failures.get(walletResourceKey(userId, characterId));
|
||||
if (!failure || failure.userId !== userId || failure.characterId !== characterId) return null;
|
||||
const current = await this.currentAuthority(userId, characterId);
|
||||
if (!current || failure.authorizationRevision !== current.grantRevision || failure.authorizationEpoch !== current.authorizationEpoch || failure.credentialVersion !== current.credentialVersion) return null;
|
||||
return cloneFailure(failure);
|
||||
}
|
||||
|
||||
public async currentAuthority(userId: string, characterId: number): Promise<WalletAuthorityFence | null> {
|
||||
const resource = this.resources.get(walletResourceKey(userId, characterId));
|
||||
return resource ? walletFenceForResource(resource) : null;
|
||||
}
|
||||
|
||||
public async credential(fence: WalletAuthorityFence): Promise<WalletCredential | null> {
|
||||
validateWalletAuthorityFence(fence);
|
||||
const current = await this.currentAuthority(fence.userId, fence.characterId);
|
||||
if (!current || !sameWalletFence(current, fence)) return null;
|
||||
const credential = this.credentials.get(walletResourceKey(fence.userId, fence.characterId));
|
||||
if (!credential) return null;
|
||||
const key = credential.authorizationKey;
|
||||
if (typeof credential.accessToken !== 'string' || !credential.accessToken || !key || key.userId !== fence.userId || key.principal !== walletPrincipalKey(fence.principal) || key.characterId !== fence.characterId || key.grantRevision !== fence.grantRevision || key.authorizationEpoch !== fence.authorizationEpoch || key.credentialVersion !== fence.credentialVersion || canonicalScopes(key.scopes)?.join('\u0000') !== canonicalScopes(fence.scopes)?.join('\u0000')) return null;
|
||||
return { accessToken: credential.accessToken, authorizationKey: { ...key, scopes: [...key.scopes] } };
|
||||
}
|
||||
|
||||
public async publishSnapshot(publication: WalletSnapshotPublication): Promise<WalletPublicationResult> {
|
||||
validateWalletAuthorityFence(publication.fence);
|
||||
validateWalletSnapshot(publication.snapshot);
|
||||
if (!Number.isFinite(publication.publishedAt) || publication.publishedAt < 0 || publication.snapshot.fetchedAt > publication.publishedAt) return { accepted: false, reason: 'invalid_snapshot' };
|
||||
if (publication.snapshot.userId !== publication.fence.userId || publication.snapshot.characterId !== publication.fence.characterId || publication.snapshot.principal.id !== publication.fence.principal.id || publication.snapshot.principal.ownerUserId !== publication.fence.principal.ownerUserId || publication.snapshot.authorizationRevision !== publication.fence.grantRevision || publication.snapshot.authorizationEpoch !== publication.fence.authorizationEpoch || publication.snapshot.credentialVersion !== publication.fence.credentialVersion) return { accepted: false, reason: 'mismatched_character' };
|
||||
const current = await this.currentAuthority(publication.fence.userId, publication.fence.characterId);
|
||||
if (!current) return { accepted: false, reason: 'authorization_revoked' };
|
||||
if (!sameWalletFence(current, publication.fence)) {
|
||||
if (current.grantRevision !== publication.fence.grantRevision) return { accepted: false, reason: 'stale_grant' };
|
||||
if (current.authorizationEpoch !== publication.fence.authorizationEpoch) return { accepted: false, reason: 'stale_authorization_epoch' };
|
||||
if (current.credentialVersion !== publication.fence.credentialVersion) return { accepted: false, reason: 'stale_credential' };
|
||||
return { accepted: false, reason: 'borrowed_resource' };
|
||||
}
|
||||
const previousJob = this.publishedJobs.get(publication.jobId);
|
||||
if (previousJob) {
|
||||
if (previousJob.outcome === 'success' && sameWalletFence(previousJob.fence, publication.fence) && sameSnapshot(previousJob.snapshot, publication.snapshot)) return { accepted: true, snapshot: cloneSnapshot(previousJob.snapshot), outcome: 'success' };
|
||||
return { accepted: false, reason: 'duplicate_publication' };
|
||||
}
|
||||
const key = walletResourceKey(publication.fence.userId, publication.fence.characterId);
|
||||
const prior = this.snapshots.get(key);
|
||||
const priorFailure = this.failures.get(key);
|
||||
if (priorFailure && priorFailure.authorizationRevision === publication.fence.grantRevision && priorFailure.authorizationEpoch === publication.fence.authorizationEpoch && priorFailure.credentialVersion === publication.fence.credentialVersion && priorFailure.failedAt >= publication.snapshot.fetchedAt) return { accepted: false, reason: 'stale_generation' };
|
||||
if (prior) {
|
||||
if (prior.generation === publication.snapshot.generation) {
|
||||
if (!sameSnapshot(prior, publication.snapshot)) return { accepted: false, reason: 'stale_generation' };
|
||||
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'success', snapshot: cloneSnapshot(prior) });
|
||||
return { accepted: true, snapshot: cloneSnapshot(prior), outcome: 'success' };
|
||||
}
|
||||
if (prior.fetchedAt >= publication.snapshot.fetchedAt) return { accepted: false, reason: 'stale_generation' };
|
||||
}
|
||||
this.snapshots.set(key, cloneSnapshot(publication.snapshot));
|
||||
if (!priorFailure || priorFailure.failedAt < publication.snapshot.fetchedAt) this.failures.delete(key);
|
||||
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'success', snapshot: cloneSnapshot(publication.snapshot) });
|
||||
return { accepted: true, snapshot: cloneSnapshot(publication.snapshot), outcome: 'success' };
|
||||
}
|
||||
|
||||
public async publishFailure(publication: WalletFailurePublication): Promise<WalletPublicationResult> {
|
||||
validateWalletAuthorityFence(publication.fence);
|
||||
validateFailure(publication.failure);
|
||||
if (publication.failure.userId !== publication.fence.userId || publication.failure.characterId !== publication.fence.characterId || publication.failure.authorizationRevision !== publication.fence.grantRevision || publication.failure.authorizationEpoch !== publication.fence.authorizationEpoch || publication.failure.credentialVersion !== publication.fence.credentialVersion || !Number.isFinite(publication.publishedAt) || publication.failure.failedAt > publication.publishedAt) return { accepted: false, reason: 'mismatched_character' };
|
||||
const current = await this.currentAuthority(publication.fence.userId, publication.fence.characterId);
|
||||
if (!current) return { accepted: false, reason: 'authorization_revoked' };
|
||||
if (!sameWalletFence(current, publication.fence)) return { accepted: false, reason: current.grantRevision !== publication.fence.grantRevision ? 'stale_grant' : current.authorizationEpoch !== publication.fence.authorizationEpoch ? 'stale_authorization_epoch' : current.credentialVersion !== publication.fence.credentialVersion ? 'stale_credential' : 'borrowed_resource' };
|
||||
const previousJob = this.publishedJobs.get(publication.jobId);
|
||||
if (previousJob) {
|
||||
if (previousJob.outcome === 'failure' && sameWalletFence(previousJob.fence, publication.fence) && sameFailure(previousJob.failure, publication.failure)) return { accepted: true, snapshot: null, outcome: 'failure' };
|
||||
return { accepted: false, reason: 'duplicate_publication' };
|
||||
}
|
||||
const key = walletResourceKey(publication.failure.userId, publication.failure.characterId);
|
||||
const prior = this.failures.get(key);
|
||||
const priorSnapshot = this.snapshots.get(key);
|
||||
if (priorSnapshot && priorSnapshot.authorizationRevision === publication.fence.grantRevision && priorSnapshot.authorizationEpoch === publication.fence.authorizationEpoch && priorSnapshot.credentialVersion === publication.fence.credentialVersion && priorSnapshot.fetchedAt >= publication.failure.failedAt) return { accepted: false, reason: 'stale_generation' };
|
||||
if (prior) {
|
||||
if (prior.generation === publication.failure.generation) {
|
||||
if (!sameFailure(prior, publication.failure)) return { accepted: false, reason: 'stale_generation' };
|
||||
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'failure', failure: cloneFailure(prior) });
|
||||
return { accepted: true, snapshot: null, outcome: 'failure' };
|
||||
}
|
||||
if (prior.failedAt >= publication.failure.failedAt) return { accepted: false, reason: 'stale_generation' };
|
||||
}
|
||||
this.failures.set(key, cloneFailure(publication.failure));
|
||||
this.publishedJobs.set(publication.jobId, { fence: cloneFence(publication.fence), outcome: 'failure', failure: cloneFailure(publication.failure) });
|
||||
return { accepted: true, snapshot: null, outcome: 'failure' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the shared component shape without inventing an upstream effective time. */
|
||||
export function walletComponent(snapshot: WalletBalanceSnapshot): WalletBalanceComponent {
|
||||
validateWalletSnapshot(snapshot);
|
||||
return {
|
||||
component: 'wallet_balance',
|
||||
principal: clonePrincipal(snapshot.principal),
|
||||
amount: snapshot.amount,
|
||||
fetchedAt: snapshot.fetchedAt,
|
||||
upstreamEffectiveAt: snapshot.upstreamEffectiveAt,
|
||||
coverage: { ...snapshot.coverage },
|
||||
authorizationRevision: snapshot.authorizationRevision,
|
||||
credentialVersion: snapshot.credentialVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export type WalletBalanceSharedComponent = WalletBalanceComponent;
|
||||
export type WalletBalanceSharedProvenance = CompleteReportProvenance;
|
||||
@@ -0,0 +1,339 @@
|
||||
import type {
|
||||
CompleteReportProvenance,
|
||||
DatasetKind,
|
||||
ReportDatasetPin,
|
||||
ReportProvenance,
|
||||
ResourcePrincipal,
|
||||
SelectedResource,
|
||||
WalletBalanceComponent,
|
||||
} from '../../domain/model';
|
||||
import { compareDecimalIds, isDecimalId } from '../../domain/inventory-location';
|
||||
import { sumIsk } from '../../domain/money';
|
||||
import { ESI_WALLET_SCOPE } from '../esi';
|
||||
import {
|
||||
validateWalletRefreshFailure,
|
||||
validateWalletScopes,
|
||||
validateWalletSnapshot,
|
||||
walletComponent,
|
||||
walletFenceForResource,
|
||||
type WalletAuthorizedResource,
|
||||
type WalletBalanceRepository,
|
||||
type WalletBalanceSnapshot,
|
||||
type WalletRefreshFailure,
|
||||
} from './balance-repository';
|
||||
import type { WalletBalanceJobQueue } from './balance-job';
|
||||
|
||||
export const DEFAULT_WALLET_MAX_CHARACTERS = 100;
|
||||
export const DEFAULT_WALLET_ENQUEUE_LIMIT = 100;
|
||||
export const WALLET_SELECTOR_MAX_IDS = 100;
|
||||
|
||||
export type WalletBalanceRequest = {
|
||||
userId: string;
|
||||
/** Character IDs are selectors only; authorization is resolved by the repository. */
|
||||
characterIds?: readonly (string | number)[];
|
||||
};
|
||||
|
||||
export type WalletFreshness = 'fresh' | 'stale' | 'missing' | 'failed' | 'consent_required' | 'unavailable';
|
||||
|
||||
export type WalletBalanceCharacterView = {
|
||||
principal: Extract<ResourcePrincipal, { kind: 'character' }>;
|
||||
characterId: number;
|
||||
characterName: string | null;
|
||||
consent: 'granted' | 'missing';
|
||||
freshness: WalletFreshness;
|
||||
amount: string | null;
|
||||
fetchedAt: number | null;
|
||||
expiresAt: number | null;
|
||||
upstreamEffectiveAt: number | null;
|
||||
authorizationRevision: number;
|
||||
authorizationEpoch: number;
|
||||
credentialVersion: number | null;
|
||||
failure: WalletRefreshFailure | null;
|
||||
};
|
||||
|
||||
type WalletBalanceCommonView = {
|
||||
characters: readonly WalletBalanceCharacterView[];
|
||||
components: readonly WalletBalanceComponent[];
|
||||
/** Explicitly wallet-only; this is never an asset/wealth total. */
|
||||
walletOnlyTotal: string | null;
|
||||
missing: readonly Extract<ResourcePrincipal, { kind: 'character' }>[];
|
||||
/** Queue outcomes are diagnostic only and never turn stale data into ready data. */
|
||||
refresh: { attempted: number; queued: number; duplicate: number; cooldown: number; skipped: number; failed: number };
|
||||
};
|
||||
|
||||
export type WalletBalanceReadyView = WalletBalanceCommonView & {
|
||||
status: 'ready';
|
||||
walletTotal: string;
|
||||
walletOnlyTotal: string;
|
||||
provenance: CompleteReportProvenance;
|
||||
};
|
||||
export type WalletBalancePartialView = WalletBalanceCommonView & {
|
||||
status: 'partial';
|
||||
walletTotal: null;
|
||||
walletOnlyTotal: null;
|
||||
provenance: ReportProvenance;
|
||||
};
|
||||
export type WalletBalanceBlockedView = WalletBalanceCommonView & {
|
||||
status: 'blocked' | 'unavailable';
|
||||
walletTotal: null;
|
||||
walletOnlyTotal: null;
|
||||
reason: string;
|
||||
provenance?: never;
|
||||
};
|
||||
/** Shared result consumed by both a future UI route and assistant adapter. */
|
||||
export type WalletBalanceView = WalletBalanceReadyView | WalletBalancePartialView | WalletBalanceBlockedView;
|
||||
|
||||
export type WalletBalanceServiceOptions = {
|
||||
repository: WalletBalanceRepository;
|
||||
queue?: WalletBalanceJobQueue;
|
||||
now?: () => number;
|
||||
maxCharacters?: number;
|
||||
enqueueLimit?: number;
|
||||
};
|
||||
|
||||
function finiteNow(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isFinite(value) || value < 0) throw new RangeError('wallet service clock returned an invalid timestamp');
|
||||
return value;
|
||||
}
|
||||
|
||||
function clonePrincipal(principal: Extract<ResourcePrincipal, { kind: 'character' }>): Extract<ResourcePrincipal, { kind: 'character' }> {
|
||||
return { ...principal };
|
||||
}
|
||||
|
||||
function validateResource(resource: WalletAuthorizedResource, userId: string): void {
|
||||
if (!resource || resource.userId !== userId || resource.principal.kind !== 'character' || resource.principal.ownerUserId !== userId || resource.principal.id !== String(resource.characterId) || !Number.isSafeInteger(resource.characterId) || resource.characterId <= 0 || !resource.resource || resource.resource.principal.kind !== 'character' || resource.resource.principal.id !== resource.principal.id || resource.resource.principal.ownerUserId !== userId || !validateWalletScopes(resource.resource.requiredScopes).includes(ESI_WALLET_SCOPE)) throw new TypeError('wallet repository returned malformed or borrowed resource');
|
||||
validateWalletScopes(resource.grantedScopes, true);
|
||||
if (!Number.isSafeInteger(resource.grantRevision) || resource.grantRevision < 0 || !Number.isSafeInteger(resource.authorizationEpoch) || resource.authorizationEpoch < 0 || (resource.credentialVersion !== null && (!Number.isSafeInteger(resource.credentialVersion) || resource.credentialVersion < 0))) throw new TypeError('wallet repository returned malformed authority counters');
|
||||
if (!['granted', 'missing'].includes(resource.consent) || !['active', 'revocation_pending', 'quarantined', 'revoked', 'transferred'].includes(resource.authorizationStatus)) throw new TypeError('wallet repository returned malformed consent state');
|
||||
if (resource.resource.authorizationRevision !== resource.grantRevision || resource.resource.authorizationEpoch !== resource.authorizationEpoch) throw new TypeError('wallet resource pin does not match authority counters');
|
||||
if (resource.consent === 'granted' && (!resource.grantedScopes.includes(ESI_WALLET_SCOPE) || resource.credentialVersion === null)) throw new TypeError('wallet granted resource lacks exact authority');
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(value: readonly (string | number)[] | undefined): string[] | null {
|
||||
if (value === undefined) return null;
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > WALLET_SELECTOR_MAX_IDS) throw new RangeError('wallet character selection cap exceeded');
|
||||
const ids = value.map((entry, index) => {
|
||||
if (typeof entry === 'number') {
|
||||
if (!Number.isSafeInteger(entry) || entry <= 0) throw new TypeError(`wallet characterIds[${index}] is invalid`);
|
||||
return String(entry);
|
||||
}
|
||||
if (!isDecimalId(entry)) throw new TypeError(`wallet characterIds[${index}] is invalid`);
|
||||
return entry;
|
||||
});
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError('wallet character selection contains duplicates');
|
||||
return ids.sort(compareDecimalIds);
|
||||
}
|
||||
|
||||
function sameResource(left: SelectedResource, right: SelectedResource): boolean {
|
||||
return left.principal.kind === 'character' && right.principal.kind === 'character' && left.principal.id === right.principal.id && left.principal.ownerUserId === right.principal.ownerUserId && left.authorizationRevision === right.authorizationRevision && left.authorizationEpoch === right.authorizationEpoch && [...left.requiredScopes].sort().join('\u0000') === [...right.requiredScopes].sort().join('\u0000');
|
||||
}
|
||||
|
||||
function incompleteCoverage(): Extract<WalletBalanceSnapshot['coverage'], { kind: 'snapshot' }> {
|
||||
return { kind: 'snapshot', generation: null, complete: false };
|
||||
}
|
||||
|
||||
function resourceMaps(resources: readonly WalletAuthorizedResource[]) {
|
||||
const authorizationRevisions: Record<string, number> = {};
|
||||
const authorizationEpochs: Record<string, number> = {};
|
||||
const snapshotGenerations: Record<string, string> = {};
|
||||
for (const resource of resources) {
|
||||
authorizationRevisions[`character:${resource.characterId}`] = resource.grantRevision;
|
||||
authorizationEpochs[`character:${resource.characterId}`] = resource.authorizationEpoch;
|
||||
}
|
||||
return { authorizationRevisions, authorizationEpochs, snapshotGenerations };
|
||||
}
|
||||
|
||||
function provenance(resources: readonly WalletAuthorizedResource[], snapshots: ReadonlyMap<number, WalletBalanceSnapshot>, now: number, complete: boolean): ReportProvenance | CompleteReportProvenance {
|
||||
const selectedResources = resources.map((resource) => ({ ...resource.resource, principal: clonePrincipal(resource.principal), requiredScopes: [...resource.resource.requiredScopes] }));
|
||||
const maps = resourceMaps(resources);
|
||||
const datasetPins: ReportDatasetPin[] = resources.map((resource) => {
|
||||
const snapshot = snapshots.get(resource.characterId);
|
||||
if (snapshot) maps.snapshotGenerations[`character:${resource.characterId}`] = snapshot.generation;
|
||||
return {
|
||||
selectedResource: resource.resource,
|
||||
principal: resource.principal,
|
||||
dataset: 'wallet_balance',
|
||||
coverage: snapshot?.coverage ?? incompleteCoverage(),
|
||||
authorizationRevision: resource.grantRevision,
|
||||
authorizationEpoch: resource.authorizationEpoch,
|
||||
fetchedAt: snapshot?.fetchedAt ?? null,
|
||||
upstreamEffectiveAt: snapshot?.upstreamEffectiveAt ?? null,
|
||||
};
|
||||
});
|
||||
const base: ReportProvenance = {
|
||||
reportId: `wallet-${now}-${selectedResources.map((resource) => resource.principal.id).join(',')}`,
|
||||
generatedAt: now,
|
||||
fetchedAt: Object.fromEntries(resources.map((resource) => [`character:${resource.characterId}`, snapshots.get(resource.characterId)?.fetchedAt ?? null])),
|
||||
upstreamEffectiveAt: Object.fromEntries(resources.map((resource) => [`character:${resource.characterId}`, snapshots.get(resource.characterId)?.upstreamEffectiveAt ?? null])),
|
||||
input: {
|
||||
selectedResources,
|
||||
requiredDatasetKinds: ['wallet_balance'] as readonly [DatasetKind],
|
||||
datasetPins,
|
||||
authorizationRevisions: maps.authorizationRevisions,
|
||||
authorizationEpochs: maps.authorizationEpochs,
|
||||
snapshotGenerations: maps.snapshotGenerations,
|
||||
appendCheckpoints: {},
|
||||
referenceVersion: null,
|
||||
priceVersion: null,
|
||||
...(complete ? { pinSet: 'exact_selected_resource_dataset_set' as const } : {}),
|
||||
} as ReportProvenance['input'],
|
||||
sourceSkew: false,
|
||||
assumptions: ['Wallet balance is a separately labelled cash component; assets, orders, journals and wealth are not included.', 'ESI supplied no upstream effective timestamp; upstreamEffectiveAt is null.'],
|
||||
};
|
||||
return complete ? base as CompleteReportProvenance : base;
|
||||
}
|
||||
|
||||
function characterView(resource: WalletAuthorizedResource, snapshot: WalletBalanceSnapshot | null, failure: WalletRefreshFailure | null, now: number): WalletBalanceCharacterView {
|
||||
const allowed = resource.consent === 'granted' && resource.authorizationStatus === 'active' && resource.credentialVersion !== null;
|
||||
if (!allowed) return {
|
||||
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'missing', freshness: 'consent_required', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null,
|
||||
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: null,
|
||||
};
|
||||
if (!snapshot) return {
|
||||
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'granted', freshness: failure ? 'failed' : 'missing', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null,
|
||||
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure,
|
||||
};
|
||||
const failed = failure !== null && failure.failedAt >= snapshot.fetchedAt;
|
||||
return {
|
||||
principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: 'granted', freshness: failed ? 'failed' : now < snapshot.expiresAt ? 'fresh' : 'stale', amount: snapshot.amount, fetchedAt: snapshot.fetchedAt, expiresAt: snapshot.expiresAt, upstreamEffectiveAt: snapshot.upstreamEffectiveAt,
|
||||
authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: failed ? failure : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic wallet read core shared by an eventual UI route and assistant
|
||||
* adapter. Opening the view may enqueue stale observations, but never waits for
|
||||
* a refresh or replaces a valid cache with an unnecessary job.
|
||||
*/
|
||||
export class WalletBalanceService {
|
||||
private readonly now: () => number;
|
||||
private readonly maxCharacters: number;
|
||||
private readonly enqueueLimit: number;
|
||||
|
||||
public constructor(private readonly options: WalletBalanceServiceOptions) {
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.maxCharacters = options.maxCharacters ?? DEFAULT_WALLET_MAX_CHARACTERS;
|
||||
this.enqueueLimit = options.enqueueLimit ?? DEFAULT_WALLET_ENQUEUE_LIMIT;
|
||||
if (!Number.isSafeInteger(this.maxCharacters) || this.maxCharacters < 1 || !Number.isSafeInteger(this.enqueueLimit) || this.enqueueLimit < 1) throw new RangeError('wallet service bounds are invalid');
|
||||
}
|
||||
|
||||
public async read(request: WalletBalanceRequest): Promise<WalletBalanceView> {
|
||||
if (!request || typeof request.userId !== 'string' || !request.userId) throw new TypeError('wallet user is required');
|
||||
const now = finiteNow(this.now);
|
||||
const selectedIds = normalizeSelectedIds(request.characterIds);
|
||||
const all = await this.options.repository.authorizedResources(request.userId);
|
||||
if (!Array.isArray(all) || all.length > this.maxCharacters) throw new RangeError('wallet authorized resource cap exceeded');
|
||||
const seen = new Set<string>();
|
||||
for (const resource of all) {
|
||||
validateResource(resource, request.userId);
|
||||
if (seen.has(resource.principal.id)) throw new TypeError('wallet repository returned duplicate characters');
|
||||
seen.add(resource.principal.id);
|
||||
}
|
||||
let resources: WalletAuthorizedResource[];
|
||||
if (selectedIds === null) resources = [...all];
|
||||
else {
|
||||
resources = [];
|
||||
for (const id of selectedIds) {
|
||||
const resource = all.find((candidate) => candidate.principal.id === id);
|
||||
if (!resource) return { status: 'blocked', characters: [], components: [], walletTotal: null, walletOnlyTotal: null, missing: [], reason: 'Selected character is not an authorized linked resource', refresh: { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 } };
|
||||
resources.push(resource);
|
||||
}
|
||||
}
|
||||
resources.sort((a, b) => compareDecimalIds(a.principal.id, b.principal.id));
|
||||
if (resources.length === 0) return { status: 'blocked', characters: [], components: [], walletTotal: null, walletOnlyTotal: null, missing: [], reason: 'No authorized linked wallet resources are available', refresh: { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 } };
|
||||
|
||||
const snapshots = new Map<number, WalletBalanceSnapshot>();
|
||||
const failures = new Map<number, WalletRefreshFailure>();
|
||||
const views: WalletBalanceCharacterView[] = [];
|
||||
const refresh = { attempted: 0, queued: 0, duplicate: 0, cooldown: 0, skipped: 0, failed: 0 };
|
||||
for (const resource of resources) {
|
||||
let snapshot: WalletBalanceSnapshot | null = null;
|
||||
let failure: WalletRefreshFailure | null = null;
|
||||
try {
|
||||
snapshot = await this.options.repository.snapshot(request.userId, resource.characterId);
|
||||
failure = await this.options.repository.failure(request.userId, resource.characterId);
|
||||
} catch {
|
||||
views.push({ principal: clonePrincipal(resource.principal), characterId: resource.characterId, characterName: resource.characterName ?? null, consent: resource.consent, freshness: 'unavailable', amount: null, fetchedAt: null, expiresAt: null, upstreamEffectiveAt: null, authorizationRevision: resource.grantRevision, authorizationEpoch: resource.authorizationEpoch, credentialVersion: resource.credentialVersion, failure: null });
|
||||
refresh.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (snapshot) {
|
||||
validateWalletSnapshot(snapshot);
|
||||
if (snapshot.fetchedAt > now || (snapshot.upstreamEffectiveAt !== null && snapshot.upstreamEffectiveAt > snapshot.fetchedAt)) throw new TypeError('wallet repository returned future or inconsistent snapshot provenance');
|
||||
if (snapshot.userId !== request.userId || snapshot.characterId !== resource.characterId || !sameResource({ ...resource.resource, principal: resource.principal }, snapshotToResource(snapshot, resource))) throw new TypeError('wallet repository snapshot authority does not match selected resource');
|
||||
if (snapshot.authorizationRevision !== resource.grantRevision || snapshot.authorizationEpoch !== resource.authorizationEpoch || snapshot.credentialVersion !== resource.credentialVersion) {
|
||||
// A snapshot from an earlier authority epoch remains stored for audit,
|
||||
// but it is not readable data for this request.
|
||||
snapshot = null;
|
||||
} else snapshots.set(resource.characterId, snapshot);
|
||||
}
|
||||
if (failure) {
|
||||
validateWalletRefreshFailure(failure);
|
||||
if (failure.failedAt > now) throw new TypeError('wallet repository returned future failure provenance');
|
||||
if (failure.userId !== request.userId || failure.characterId !== resource.characterId || failure.authorizationRevision !== resource.grantRevision || failure.authorizationEpoch !== resource.authorizationEpoch || failure.credentialVersion !== resource.credentialVersion) failure = null;
|
||||
}
|
||||
if (failure) failures.set(resource.characterId, failure);
|
||||
const view = characterView(resource, snapshot, failure, now);
|
||||
views.push(view);
|
||||
const stale = view.consent === 'granted' && (view.freshness === 'stale' || view.freshness === 'missing' || (view.freshness === 'failed' && failure?.retryable !== false));
|
||||
if (stale && this.options.queue && refresh.attempted < this.enqueueLimit) {
|
||||
refresh.attempted += 1;
|
||||
const fence = walletFenceForResource(resource);
|
||||
if (!fence) { refresh.skipped += 1; continue; }
|
||||
try {
|
||||
const result = await this.options.queue.enqueue({ userId: request.userId, characterId: resource.characterId, fence, now });
|
||||
if (result.status === 'queued') refresh.queued += 1;
|
||||
else if (result.status === 'duplicate') refresh.duplicate += 1;
|
||||
else if (result.status === 'cooldown') refresh.cooldown += 1;
|
||||
else refresh.failed += 1;
|
||||
} catch { refresh.failed += 1; }
|
||||
} else if (stale) refresh.skipped += 1;
|
||||
}
|
||||
|
||||
const components = resources.map((resource) => snapshots.get(resource.characterId)).filter((snapshot): snapshot is WalletBalanceSnapshot => snapshot !== undefined && resourceIsGranted(resources, snapshot.characterId)).map(walletComponent);
|
||||
const missing = views.filter((view) => view.freshness !== 'fresh').map((view) => clonePrincipal(view.principal));
|
||||
const complete = views.length === resources.length && views.every((view) => view.freshness === 'fresh' && view.consent === 'granted') && components.length === resources.length;
|
||||
const walletTotal = complete ? sumIsk(components.map((component) => component.amount)) : null;
|
||||
const report = provenance(resources, snapshots, now, complete);
|
||||
if (complete) return {
|
||||
status: 'ready',
|
||||
characters: views,
|
||||
components,
|
||||
walletTotal: walletTotal!,
|
||||
walletOnlyTotal: walletTotal!,
|
||||
missing,
|
||||
provenance: report as CompleteReportProvenance,
|
||||
refresh,
|
||||
};
|
||||
return {
|
||||
status: 'partial',
|
||||
characters: views,
|
||||
components,
|
||||
walletTotal: null,
|
||||
walletOnlyTotal: null,
|
||||
missing,
|
||||
provenance: report as ReportProvenance,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
public execute(request: WalletBalanceRequest): Promise<WalletBalanceView> { return this.read(request); }
|
||||
}
|
||||
|
||||
function resourceIsGranted(resources: readonly WalletAuthorizedResource[], characterId: number): boolean {
|
||||
const resource = resources.find((candidate) => candidate.characterId === characterId);
|
||||
return !!resource && resource.consent === 'granted' && resource.authorizationStatus === 'active';
|
||||
}
|
||||
|
||||
function snapshotToResource(snapshot: WalletBalanceSnapshot, resource: WalletAuthorizedResource): SelectedResource {
|
||||
return { ...resource.resource, principal: { ...snapshot.principal }, requiredScopes: [...resource.resource.requiredScopes] };
|
||||
}
|
||||
|
||||
export async function readWalletBalance(options: WalletBalanceServiceOptions, request: WalletBalanceRequest): Promise<WalletBalanceView> {
|
||||
return new WalletBalanceService(options).read(request);
|
||||
}
|
||||
|
||||
export const queryWalletBalance = readWalletBalance;
|
||||
export type WalletBalanceResult = WalletBalanceView;
|
||||
export type WalletViewResult = WalletBalanceView;
|
||||
Reference in New Issue
Block a user