feat: add typed ESI transport extensions

This commit is contained in:
jb
2026-09-19 15:43:22 -04:00
parent 4d3c7a7521
commit 2675ea8114
14 changed files with 1205 additions and 124 deletions
+31 -1
View File
@@ -540,6 +540,7 @@ export const bridgeSaveTokens = mutationGeneric({
tokenExpiresAt: v.number(),
scopes: v.array(v.string()),
expectedVersion: v.number(),
returnAuthority: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
@@ -569,6 +570,15 @@ export const bridgeSaveTokens = mutationGeneric({
datasetStatuses: updated.statuses,
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined } : {}),
});
if (args.returnAuthority) {
return {
saved: true,
credentialVersion: nextVersion,
grantRevision: nextRevision,
authorizationEpoch: character.authorizationEpoch,
scopes: args.scopes,
};
}
return true;
},
});
@@ -713,7 +723,14 @@ export const bridgeAssetsPage = queryGeneric({
});
export const bridgeBeginSync = mutationGeneric({
args: { ...bridgeArgs, ...characterArgs },
args: {
...bridgeArgs,
...characterArgs,
expectedScopes: v.optional(v.array(v.string())),
expectedGrantRevision: v.optional(v.number()),
expectedAuthorizationEpoch: v.optional(v.number()),
expectedCredentialVersion: v.optional(v.number()),
},
handler: async (ctx, args) => {
assertBridge(args.bridgeSecret);
assertDeploymentCapability('asset_sync');
@@ -722,6 +739,19 @@ export const bridgeBeginSync = mutationGeneric({
if (character.authorizationStatus !== "active" || !character.scopes.includes("esi-assets.read_assets.v1")) {
throw new Error("Asset authorization is not active");
}
if (args.expectedGrantRevision !== undefined && args.expectedGrantRevision !== character.grantRevision) {
throw new Error("Synchronization authorization changed before lease");
}
if (args.expectedAuthorizationEpoch !== undefined && args.expectedAuthorizationEpoch !== character.authorizationEpoch) {
throw new Error("Synchronization authorization changed before lease");
}
if (args.expectedCredentialVersion !== undefined && args.expectedCredentialVersion !== character.credentialVersion) {
throw new Error("Synchronization credential changed before lease");
}
if (args.expectedScopes !== undefined
&& JSON.stringify([...args.expectedScopes].sort()) !== JSON.stringify([...character.scopes].sort())) {
throw new Error("Synchronization scopes changed before lease");
}
const timestamp = now();
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > timestamp) {
throw new Error("Character synchronization is already leased");
+2
View File
@@ -48,6 +48,8 @@ export const assetValidator = v.object({
locationId: v.string(),
locationType: v.string(),
locationFlag: v.string(),
// Legacy rows may omit this source fact; absent means unknown, never inferred.
isSingleton: v.optional(v.boolean()),
isBlueprintCopy: v.optional(v.boolean()),
});
+1
View File
@@ -95,6 +95,7 @@ function isFiniteSafeTypeId(value: unknown): value is number {
function validateAsset(asset: Asset, characterId: number): string | null {
if (!asset || typeof asset.itemId !== 'string' || !/^[1-9]\d*$/.test(asset.itemId)) return 'Asset item ID is invalid';
if (asset.characterId !== characterId) return 'Asset ownership validation failed';
if (asset.isSingleton !== undefined && typeof asset.isSingleton !== 'boolean') return 'Asset singleton flag is invalid';
if (!isFiniteSafeTypeId(asset.typeId)) return 'Asset type ID is invalid';
try {
validateQuantity(asset.quantity);
+53 -6
View File
@@ -91,8 +91,13 @@ export async function assetReport(
finally { reference?.close(); }
}
const refreshes = new Map<string, Promise<VerifiedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<VerifiedGrant> {
type CommittedGrant = VerifiedGrant & {
credentialVersion: number;
grantRevision: number;
authorizationEpoch: number;
};
const refreshes = new Map<string, Promise<CommittedGrant>>();
async function characterGrant(userId: string, characterId: number, revalidateOwnership = false): Promise<CommittedGrant> {
const key = `${userId}:${characterId}`;
const existing = refreshes.get(key); if (existing) return existing;
const promise = (async () => {
@@ -101,7 +106,16 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn
if (!record) throw new Error('Character authorization is missing');
const previous = await cipher.open<VerifiedGrant>(record.encryptedTokens, `character:${characterId}`);
if (previous.characterId !== characterId) throw new Error('Character authorization mismatch');
if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) return previous;
if (!revalidateOwnership && previous.expiresAt > Date.now() + 60_000) {
const current = (await store.characters(userId)).find((entry) => entry.id === characterId);
if (!current) throw new Error('Character authorization is missing');
return {
...previous,
credentialVersion: current.credentialVersion,
grantRevision: current.grantRevision,
authorizationEpoch: current.authorizationEpoch,
};
}
const grant = await sso.refresh(previous.refreshToken);
if (grant.characterId !== characterId || grant.ownerHash !== previous.ownerHash) {
await store.quarantineAuthorization(userId, characterId).catch(() => {});
@@ -109,7 +123,14 @@ async function characterGrant(userId: string, characterId: number, revalidateOwn
}
const saved = await store.saveTokens(userId, characterId, { encryptedTokens: await cipher.seal(grant, `character:${characterId}`), tokenExpiresAt: grant.expiresAt, scopes: grant.scopes, expectedVersion: record.version });
if (!saved) throw new Error('Authorization changed concurrently; retry synchronization');
return grant;
return {
...grant,
credentialVersion: saved.credentialVersion,
grantRevision: saved.grantRevision,
authorizationEpoch: saved.authorizationEpoch,
scopes: saved.scopes,
};
})();
refreshes.set(key, promise);
try { return await promise; } finally { if (refreshes.get(key) === promise) refreshes.delete(key); }
@@ -132,8 +153,34 @@ export async function synchronize(userId: string) {
// that actually authorizes the ESI request and publication.
const grant = await characterGrant(userId, character.id, true);
if (!grant.scopes.includes('esi-assets.read_assets.v1')) throw new Error('Asset authorization missing');
generation = await store.beginSync(userId, character.id);
const snapshot = await esi.getAssets(character.id, grant.accessToken, `${userId}:${character.id}:${grant.ownerHash}:${[...grant.scopes].sort().join(',')}`);
// Refresh/CAS may have advanced authority while the initial character
// list was in memory. Reload before leasing and bind beginSync to the
// exact revisions/scopes that authorize the transport request.
const current = (await store.characters(userId)).find((entry) => entry.id === character.id);
if (!current || current.authorizationStatus !== 'active'
|| !current.scopes.includes('esi-assets.read_assets.v1')
|| current.credentialVersion !== grant.credentialVersion
|| current.grantRevision !== grant.grantRevision
|| current.authorizationEpoch !== grant.authorizationEpoch
|| JSON.stringify([...current.scopes].sort()) !== JSON.stringify([...grant.scopes].sort())) {
throw new Error('Asset authorization changed after credential commit');
}
const authority = {
userId,
principal: grant.ownerHash,
characterId: current.id,
scopes: current.scopes,
grantRevision: grant.grantRevision,
authorizationEpoch: grant.authorizationEpoch,
credentialVersion: grant.credentialVersion,
} as const;
generation = await store.beginSync(userId, current.id, {
scopes: authority.scopes,
grantRevision: authority.grantRevision,
authorizationEpoch: authority.authorizationEpoch,
credentialVersion: authority.credentialVersion,
});
const snapshot = await esi.getAssets(current.id, grant.accessToken, authority);
const chunks = Math.ceil(snapshot.assets.length / 250);
for (let index = 0; index < chunks; index++) await store.stageAssets(userId, character.id, generation, index, snapshot.assets.slice(index * 250, (index + 1) * 250));
await store.finishSync(userId, character.id, generation, chunks, snapshot.syncedAt, snapshot.expiresAt);
File diff suppressed because it is too large Load Diff
+33 -4
View File
@@ -47,6 +47,14 @@ export type TokenRecord = {
scopes: string[];
};
export type SavedTokenAuthority = {
saved: true;
credentialVersion: number;
grantRevision: number;
authorizationEpoch: number;
scopes: string[];
};
export type SaveInteractionInput = {
requestId: string;
message: string;
@@ -210,12 +218,15 @@ export class ConvexStore implements CharacterAssetRepository {
scopes: string[];
expectedVersion: number;
},
): Promise<boolean> {
return await this.mutation<boolean>(MUTATION.saveTokens, {
): Promise<SavedTokenAuthority | false> {
const result = await this.mutation<boolean | SavedTokenAuthority>(MUTATION.saveTokens, {
userId,
characterId,
...input,
returnAuthority: true,
});
if (result === true) throw new Error("Token save did not return committed authority");
return result;
}
public async characters(userId: UserId): Promise<Character[]> {
@@ -283,8 +294,26 @@ export class ConvexStore implements CharacterAssetRepository {
throw new Error(SNAPSHOT_CHANGED);
}
public async beginSync(userId: string, characterId: number): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, { userId, characterId });
public async beginSync(
userId: string,
characterId: number,
expectedAuthority?: {
scopes: readonly string[];
grantRevision: number;
authorizationEpoch: number;
credentialVersion?: number;
},
): Promise<string> {
return await this.mutation<string>(MUTATION.beginSync, {
userId,
characterId,
...(expectedAuthority === undefined ? {} : {
expectedScopes: [...expectedAuthority.scopes],
expectedGrantRevision: expectedAuthority.grantRevision,
expectedAuthorizationEpoch: expectedAuthority.authorizationEpoch,
...(expectedAuthority.credentialVersion === undefined ? {} : { expectedCredentialVersion: expectedAuthority.credentialVersion }),
}),
});
}
public async stageAssets(