feat: add typed ESI transport extensions
This commit is contained in:
+31
-1
@@ -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");
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+651
-80
File diff suppressed because it is too large
Load Diff
+33
-4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user