2006 lines
118 KiB
TypeScript
2006 lines
118 KiB
TypeScript
import {
|
|
mutationGeneric,
|
|
paginationOptsValidator,
|
|
queryGeneric,
|
|
} from "convex/server";
|
|
import { v } from "convex/values";
|
|
import { assetValidator } from "./schema";
|
|
import { deterministicEnrichmentVersion, MAX_LOCATION_ENRICHMENT_CHUNK, resolveContainment, validatePersistedLocationRows, validateReferenceEnrichmentPin } from "../lib/domain/inventory-location";
|
|
import { capabilityEnabled, parseBackendCapabilityAllowlist, type ReleaseCapability } from "../lib/server/release-gate";
|
|
import {
|
|
CAPABILITY_DEFINITIONS,
|
|
SERVER_SCOPE_ALLOWLIST,
|
|
validateCapabilityScopes,
|
|
} from "../lib/domain/capabilities";
|
|
import type { Capability } from "../lib/domain/model";
|
|
|
|
const MAX_ASSET_CHUNK = 250;
|
|
const MAX_LOCATION_CHUNK = MAX_LOCATION_ENRICHMENT_CHUNK;
|
|
const MAX_LOCATION_CHUNK_BYTES = 512 * 1024;
|
|
const MAX_ENRICHMENT_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
// Initial T08 persistence cap is deliberately conservative: larger optional
|
|
// enrichment falls back to a truthful failed/partial result while raw assets
|
|
// remain publishable. Increase only with measured production transaction data.
|
|
const MAX_PERSISTED_ENRICHMENT_ROWS = 2_500;
|
|
// A 50k generation can retain both helper ID plans without truncation; the
|
|
// nested validator below still caps every list and scalar independently.
|
|
const MAX_ENRICHMENT_PIN_BYTES = 8 * 1024 * 1024;
|
|
const MAX_ASSET_ROWS = 50_000;
|
|
const SYNC_LEASE_MS = 5 * 60 * 1000;
|
|
// Convex transactions must validate the manifest within bounded read limits.
|
|
const MAX_CHUNKS = 4_096;
|
|
const MAX_ERROR_LENGTH = 160;
|
|
const OAUTH_MAX_LIFETIME_MS = 10 * 60 * 1000;
|
|
const SNAPSHOT_MAX_LIFETIME_MS = 60 * 60 * 1000;
|
|
const MAX_USER_ACTIVE_SYNC_JOBS = 8;
|
|
const SYNC_PROVIDER_GROUP = 'esi:assets';
|
|
const DATASET_SCOPE_REQUIREMENTS: Record<string, readonly string[]> = {
|
|
assets: ['esi-assets.read_assets.v1'],
|
|
wallet_balance: ['esi-wallet.read_character_wallet.v1'],
|
|
wallet_journal: ['esi-wallet.read_character_wallet.v1'],
|
|
wallet_transactions: ['esi-wallet.read_character_wallet.v1'],
|
|
blueprints: ['esi-characters.read_blueprints.v1'],
|
|
skills: ['esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1'],
|
|
};
|
|
const DATASET_NAMES = Object.keys(DATASET_SCOPE_REQUIREMENTS);
|
|
const CONSENT_INTENTS = ['initial_login', 'link_character', 'upgrade_dataset', 'reconnect'] as const;
|
|
const CONSENT_CAPABILITIES = Object.keys(CAPABILITY_DEFINITIONS) as Capability[];
|
|
|
|
const bridgeArgs = { bridgeSecret: v.string() };
|
|
const workerBridgeArgs = { workerSecret: v.string() };
|
|
const characterArgs = { userId: v.string(), characterId: v.number() };
|
|
|
|
const assetPageResultValidator = v.object({
|
|
page: v.array(assetValidator),
|
|
isDone: v.boolean(),
|
|
continueCursor: v.string(),
|
|
generation: v.union(v.string(), v.null()),
|
|
enrichmentStatus: v.union(v.string(), v.null()),
|
|
enrichmentVersion: v.union(v.string(), v.null()),
|
|
snapshotChanged: v.boolean(),
|
|
});
|
|
const enrichmentManifestValidator = v.object({
|
|
generation: v.string(),
|
|
status: v.union(v.string(), v.null()),
|
|
version: v.union(v.string(), v.null()),
|
|
inputPin: v.union(v.any(), v.null()),
|
|
unresolvedCount: v.union(v.number(), v.null()),
|
|
});
|
|
const enrichmentPageValidator = v.object({
|
|
chunks: v.array(v.object({ index: v.number(), rows: v.array(v.any()) })),
|
|
isDone: v.boolean(),
|
|
continueCursor: v.string(),
|
|
generation: v.union(v.string(), v.null()),
|
|
status: v.union(v.string(), v.null()),
|
|
version: v.union(v.string(), v.null()),
|
|
});
|
|
|
|
function now(): number {
|
|
return Date.now();
|
|
}
|
|
|
|
/**
|
|
* Bridge authentication is deliberately separate from Convex's deployment
|
|
* admin key. The latter is only for operators and is never accepted here.
|
|
*/
|
|
function assertBridge(secret: string): void {
|
|
const expected = process.env.CONVEX_BRIDGE_SECRET;
|
|
if (!expected || !constantTimeEqual(secret, expected)) {
|
|
throw new Error("Convex bridge authentication failed");
|
|
}
|
|
}
|
|
|
|
function assertSyncWorker(secret: string): void {
|
|
const expected = process.env.CONVEX_SYNC_WORKER_SECRET;
|
|
if (!expected || expected === process.env.CONVEX_BRIDGE_SECRET || !constantTimeEqual(secret, expected)) throw new Error("Sync worker authentication failed");
|
|
assertDeploymentCapability('asset_sync');
|
|
}
|
|
|
|
function assertDeploymentCapability(capability: ReleaseCapability): void {
|
|
let enabled = false;
|
|
try {
|
|
enabled = capabilityEnabled(capability, parseBackendCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST));
|
|
} catch {
|
|
// Invalid backend rollout configuration fails closed.
|
|
}
|
|
if (!enabled) throw new Error(`The ${capability} capability is disabled by deployment configuration.`);
|
|
}
|
|
|
|
function constantTimeEqual(a: string, b: string): boolean {
|
|
let difference = a.length ^ b.length;
|
|
const length = Math.max(a.length, b.length);
|
|
for (let index = 0; index < length; index += 1) {
|
|
difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
/** Do not persist ESI/provider response text for a browser-facing status. */
|
|
function safeSyncError(_error: string): string {
|
|
return "Asset synchronization failed";
|
|
}
|
|
|
|
function workerJob(job: any): boolean {
|
|
return job?.kind === 'asset_sync' && job?.dataset === 'assets' && job?.authority === 'user' && job?.ownerKind === 'user';
|
|
}
|
|
|
|
function authorityMatches(character: any, job: any): boolean {
|
|
return Boolean(character && character.authorizationStatus === 'active'
|
|
&& character.scopes.includes('esi-assets.read_assets.v1')
|
|
&& character.grantRevision === job.grantRevision
|
|
&& character.authorizationEpoch === job.authorizationEpoch
|
|
&& character.credentialVersion === job.credentialVersion
|
|
&& scopesEqual(character.scopes, job.scopes ?? []));
|
|
}
|
|
|
|
function newIdentifier(prefix: string): string {
|
|
return `${prefix}_${crypto.randomUUID()}`;
|
|
}
|
|
|
|
async function findCharacter(ctx: any, userId: string, characterId: number): Promise<any | null> {
|
|
return await ctx.db
|
|
.query("characters")
|
|
.withIndex("by_user_character", (q: any) =>
|
|
q.eq("userId", userId).eq("characterId", characterId),
|
|
)
|
|
.first();
|
|
}
|
|
|
|
async function findSessionByHash(ctx: any, sessionHash: string): Promise<any | null> {
|
|
return await ctx.db
|
|
.query("sessions")
|
|
.withIndex("by_session_hash", (q: any) => q.eq("sessionHash", sessionHash))
|
|
.first();
|
|
}
|
|
|
|
function scopesEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
return left.length === right.length && [...left].sort().every((scope, index) => scope === [...right].sort()[index]);
|
|
}
|
|
|
|
function enrichmentAuthorityMatches(character: any, generation: any): boolean {
|
|
if (!character || !generation || character.authorizationStatus !== 'active'
|
|
|| generation.grantRevision !== character.grantRevision
|
|
|| generation.authorizationEpoch !== character.authorizationEpoch
|
|
|| generation.credentialVersion !== character.credentialVersion) return false;
|
|
const pin = generation.enrichmentInputPin;
|
|
if (!pin) return generation.enrichmentStatus === 'failed' || generation.enrichmentStatus === 'unavailable';
|
|
const authority = pin.authority;
|
|
return Boolean(authority && authority.userId === character.userId && authority.principal === character.ownerHash && authority.characterId === character.characterId
|
|
&& authority.grantRevision === character.grantRevision
|
|
&& authority.authorizationEpoch === character.authorizationEpoch
|
|
&& authority.credentialVersion === character.credentialVersion
|
|
&& Array.isArray(authority.scopes) && scopesEqual(authority.scopes, character.scopes));
|
|
}
|
|
|
|
function validActualScopes(scopes: readonly string[]): boolean {
|
|
return new Set(scopes).size === scopes.length && scopes.every((scope) => SERVER_SCOPE_ALLOWLIST.includes(scope));
|
|
}
|
|
|
|
function scopesSubset(subset: readonly string[], superset: readonly string[]): boolean {
|
|
const allowed = new Set(superset);
|
|
return subset.every((scope) => allowed.has(scope));
|
|
}
|
|
|
|
function datasetStatusesFor(character: any): any[] {
|
|
return character.datasetStatuses;
|
|
}
|
|
|
|
function updateDatasetStatuses(
|
|
character: any,
|
|
scopes: readonly string[],
|
|
grantRevision: number,
|
|
authorizationEpoch: number,
|
|
credentialVersion: number,
|
|
timestamp: number,
|
|
): { statuses: any[]; assetsInvalidated: boolean } {
|
|
const previous = datasetStatusesFor(character);
|
|
const statuses: any[] = [];
|
|
let assetsInvalidated = false;
|
|
for (const dataset of DATASET_NAMES) {
|
|
const old = previous.find((entry: any) => entry.dataset === dataset);
|
|
const required = DATASET_SCOPE_REQUIREMENTS[dataset]!;
|
|
const authorized = required.every((scope) => scopes.includes(scope));
|
|
if (!authorized) {
|
|
statuses.push({
|
|
dataset,
|
|
status: 'blocked',
|
|
grantRevision,
|
|
authorizationEpoch,
|
|
credentialVersion,
|
|
invalidatedAt: timestamp,
|
|
...(dataset === 'assets' ? {} : {}),
|
|
});
|
|
if (dataset === 'assets') assetsInvalidated = true;
|
|
continue;
|
|
}
|
|
// A newly authorized dataset has no coverage. Existing coverage remains
|
|
// usable only when its authority epoch/revision still matches.
|
|
const wasCurrent = old && old.grantRevision === grantRevision && old.authorizationEpoch === authorizationEpoch;
|
|
const status = wasCurrent ? old.status : old?.status === 'ready' ? 'ready' : 'never';
|
|
statuses.push({
|
|
dataset,
|
|
status,
|
|
grantRevision,
|
|
authorizationEpoch,
|
|
credentialVersion,
|
|
...(status === 'ready' && old?.generation === undefined && dataset === 'assets' && character.activeGeneration !== undefined
|
|
? { generation: character.activeGeneration } : {}),
|
|
...(old?.generation !== undefined && status === 'ready' ? { generation: old.generation } : {}),
|
|
...(old?.syncedAt === undefined ? {} : { syncedAt: old.syncedAt }),
|
|
...(old?.error === undefined ? {} : { error: old.error }),
|
|
});
|
|
}
|
|
return { statuses, assetsInvalidated };
|
|
}
|
|
|
|
function quarantinedDatasetStatuses(character: any, credentialVersion: number): any[] {
|
|
return DATASET_NAMES.map((dataset) => ({
|
|
dataset,
|
|
status: 'quarantined',
|
|
grantRevision: character.grantRevision,
|
|
authorizationEpoch: character.authorizationEpoch + 1,
|
|
credentialVersion,
|
|
invalidatedAt: now(),
|
|
}));
|
|
}
|
|
|
|
async function quarantineCharacter(ctx: any, character: any, reason = 'Authorization quarantined'): Promise<void> {
|
|
const timestamp = now();
|
|
const epoch = character.authorizationEpoch + 1;
|
|
await ctx.db.patch(character._id, {
|
|
authorizationStatus: 'quarantined',
|
|
authorizationEpoch: epoch,
|
|
datasetStatuses: quarantinedDatasetStatuses(character, character.credentialVersion),
|
|
state: 'error',
|
|
activeGeneration: undefined,
|
|
syncGeneration: undefined,
|
|
syncLeaseExpiresAt: undefined,
|
|
error: reason.slice(0, MAX_ERROR_LENGTH),
|
|
});
|
|
const generations = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) =>
|
|
q.eq('userId', character.userId).eq('characterId', character.characterId)).collect();
|
|
for (const generation of generations) {
|
|
if (generation.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization quarantined' });
|
|
}
|
|
const jobs = await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', character.userId)).collect();
|
|
for (const job of jobs) {
|
|
if (job.characterId === character.characterId && !['succeeded', 'failed', 'cancelled'].includes(job.status)) {
|
|
await ctx.db.patch(job._id, { status: 'quarantined', updatedAt: timestamp, leaseExpiresAt: undefined });
|
|
}
|
|
}
|
|
const token = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) =>
|
|
q.eq('userId', character.userId).eq('characterId', character.characterId)).first();
|
|
if (token) await ctx.db.delete(token._id);
|
|
}
|
|
|
|
function isUsableSession(session: any, timestamp = now()): boolean {
|
|
return Boolean(
|
|
session &&
|
|
!session.revokedAt &&
|
|
session.expiresAt > timestamp,
|
|
);
|
|
}
|
|
|
|
/** Server-only session lookup. The bridge secret is required even for reads. */
|
|
export const bridgeSession = queryGeneric({
|
|
args: { ...bridgeArgs, sessionHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const session = await findSessionByHash(ctx, args.sessionHash);
|
|
if (!isUsableSession(session)) return null;
|
|
return { userId: session.userId, sessionId: session.sessionId };
|
|
},
|
|
});
|
|
|
|
/** Revocation is represented by a tombstone so JWT `sid` checks remain effective. */
|
|
export const bridgeDeleteSession = mutationGeneric({
|
|
args: { ...bridgeArgs, sessionHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const session = await findSessionByHash(ctx, args.sessionHash);
|
|
if (session && !session.revokedAt) {
|
|
await ctx.db.patch(session._id, { revokedAt: now() });
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeCreateOAuth = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
stateHash: v.string(),
|
|
bindingHash: v.string(),
|
|
userId: v.optional(v.string()),
|
|
encryptedVerifier: v.string(),
|
|
expiresAt: v.number(),
|
|
returnTo: v.string(),
|
|
intent: v.string(),
|
|
capability: v.string(),
|
|
requestedScopes: v.array(v.string()),
|
|
approvedExistingScopes: v.array(v.string()),
|
|
initiatingSessionHash: v.optional(v.string()),
|
|
targetCharacterId: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const timestamp = now();
|
|
if (
|
|
!Number.isFinite(args.expiresAt) ||
|
|
args.expiresAt <= timestamp ||
|
|
args.expiresAt > timestamp + OAUTH_MAX_LIFETIME_MS
|
|
) {
|
|
throw new Error("Invalid OAuth state expiry");
|
|
}
|
|
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
|
|
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
|
|
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
|
|
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
|
|
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
|
|
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
|
|
if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes");
|
|
// Keep this bounded cleanup opportunistic; retention/GC is intentionally
|
|
// outside this bridge transaction.
|
|
const expired = await ctx.db
|
|
.query("oauthStates")
|
|
.withIndex("by_expires_at", (q: any) => q.lte("expiresAt", timestamp))
|
|
.take(50);
|
|
for (const state of expired) await ctx.db.delete(state._id);
|
|
|
|
const existing = await ctx.db
|
|
.query("oauthStates")
|
|
.withIndex("by_state_hash", (q: any) => q.eq("stateHash", args.stateHash))
|
|
.first();
|
|
if (existing) throw new Error("OAuth state already exists");
|
|
await ctx.db.insert("oauthStates", {
|
|
stateHash: args.stateHash,
|
|
bindingHash: args.bindingHash,
|
|
...(args.userId === undefined ? {} : { userId: args.userId }),
|
|
encryptedVerifier: args.encryptedVerifier,
|
|
expiresAt: args.expiresAt,
|
|
returnTo: args.returnTo,
|
|
intent: args.intent,
|
|
capability: args.capability,
|
|
requestedScopes: args.requestedScopes,
|
|
approvedExistingScopes: args.approvedExistingScopes,
|
|
...(args.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: args.initiatingSessionHash }),
|
|
...(args.targetCharacterId === undefined ? {} : { targetCharacterId: args.targetCharacterId }),
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
/** Consume and mark the state in one mutation; concurrent callbacks cannot reuse it. */
|
|
export const bridgeConsumeOAuth = mutationGeneric({
|
|
args: { ...bridgeArgs, stateHash: v.string(), bindingHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const state = await ctx.db
|
|
.query("oauthStates")
|
|
.withIndex("by_state_binding", (q: any) =>
|
|
q.eq("stateHash", args.stateHash).eq("bindingHash", args.bindingHash),
|
|
)
|
|
.first();
|
|
if (!state || state.consumedAt !== undefined) return null;
|
|
const consumedAt = now();
|
|
await ctx.db.patch(state._id, { consumedAt });
|
|
if (state.expiresAt <= consumedAt) return null;
|
|
return {
|
|
stateHash: state.stateHash,
|
|
bindingHash: state.bindingHash,
|
|
...(state.userId === undefined ? {} : { userId: state.userId }),
|
|
encryptedVerifier: state.encryptedVerifier,
|
|
expiresAt: state.expiresAt,
|
|
returnTo: state.returnTo,
|
|
intent: state.intent,
|
|
capability: state.capability,
|
|
requestedScopes: state.requestedScopes,
|
|
approvedExistingScopes: state.approvedExistingScopes,
|
|
...(state.initiatingSessionHash === undefined ? {} : { initiatingSessionHash: state.initiatingSessionHash }),
|
|
...(state.targetCharacterId === undefined ? {} : { targetCharacterId: state.targetCharacterId }),
|
|
};
|
|
},
|
|
});
|
|
|
|
const completeLoginArgs = {
|
|
...bridgeArgs,
|
|
userId: v.optional(v.string()),
|
|
characterId: v.number(),
|
|
name: v.string(),
|
|
ownerHash: v.string(),
|
|
scopes: v.array(v.string()),
|
|
encryptedTokens: v.string(),
|
|
tokenExpiresAt: v.number(),
|
|
sessionHash: v.string(),
|
|
sessionExpiresAt: v.number(),
|
|
intent: v.string(),
|
|
capability: v.string(),
|
|
requestedScopes: v.array(v.string()),
|
|
approvedExistingScopes: v.array(v.string()),
|
|
initiatingSessionHash: v.optional(v.string()),
|
|
targetCharacterId: v.optional(v.number()),
|
|
};
|
|
|
|
/**
|
|
* Create/login/link a character and rotate its token record in one transaction.
|
|
* An owner hash is continuity evidence for this character only; it is never
|
|
* used to infer that two characters belong to the same EVE account.
|
|
*/
|
|
export const bridgeCompleteLogin = mutationGeneric({
|
|
args: completeLoginArgs,
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const timestamp = now();
|
|
if (!validActualScopes(args.scopes)) throw new Error("Invalid granted scopes");
|
|
if (args.scopes.some((scope) => !args.requestedScopes.includes(scope))) throw new Error("Granted scopes exceed the bound request");
|
|
if (!CONSENT_INTENTS.includes(args.intent as typeof CONSENT_INTENTS[number])) throw new Error("Invalid OAuth intent");
|
|
if (!CONSENT_CAPABILITIES.includes(args.capability as Capability)) throw new Error("Invalid OAuth capability");
|
|
if (args.capability === 'inventory_locations') assertDeploymentCapability('inventory_locations');
|
|
if (!validActualScopes(args.requestedScopes)) throw new Error("Invalid OAuth requested scopes");
|
|
if (!validActualScopes(args.approvedExistingScopes)) throw new Error("Invalid OAuth preserved scopes");
|
|
const scopeCheck = validateCapabilityScopes(args.capability as Capability, args.requestedScopes, args.approvedExistingScopes);
|
|
if (!scopeCheck.valid) throw new Error("Invalid OAuth capability scopes");
|
|
if (args.targetCharacterId !== undefined && args.targetCharacterId !== args.characterId) {
|
|
throw new Error("OAuth character mismatch");
|
|
}
|
|
|
|
const byCharacterId = await ctx.db
|
|
.query("characters")
|
|
.withIndex("by_character_id", (q: any) => q.eq("characterId", args.characterId))
|
|
.collect();
|
|
const conflicting = byCharacterId.filter((character: any) => character.ownerHash !== args.ownerHash);
|
|
if (conflicting.length > 0) {
|
|
// Never attach a provider character to a new owner. Quarantine every
|
|
// old row in this transaction; recovery/transfer is not part of this
|
|
// local prototype.
|
|
for (const oldCharacter of conflicting) {
|
|
await quarantineCharacter(ctx, oldCharacter, "Character ownership changed; reconnect required");
|
|
}
|
|
// Return a committed rejection so quarantine is not rolled back by a
|
|
// thrown Convex error. The server adapter converts this to a fixed
|
|
// callback failure without issuing a session.
|
|
return { userId: conflicting[0]!.userId, sessionId: "", rejected: "ownership_conflict" };
|
|
}
|
|
const existingCharacter = byCharacterId[0] ?? null;
|
|
if (args.intent === "link_character" && existingCharacter) {
|
|
throw new Error("Character is already linked; use reconnect or upgrade");
|
|
}
|
|
|
|
let resolvedUserId: string;
|
|
if (existingCharacter) {
|
|
if (args.userId !== undefined && args.userId !== existingCharacter.userId) {
|
|
throw new Error("Character belongs to another user");
|
|
}
|
|
resolvedUserId = existingCharacter.userId;
|
|
if (args.userId === undefined && existingCharacter.loginRole === "data_only") {
|
|
throw new Error("Data-only character cannot authenticate while signed out");
|
|
}
|
|
} else {
|
|
resolvedUserId = args.userId ?? newIdentifier("user");
|
|
}
|
|
|
|
const sameSession = await findSessionByHash(ctx, args.sessionHash);
|
|
if (sameSession) throw new Error("Session hash collision");
|
|
|
|
// Linking, upgrading and reconnecting are authorized by the still-live
|
|
// application session that initiated consent.
|
|
const preexistingUser = await ctx.db.query("users").withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId)).first();
|
|
if (args.userId !== undefined && !preexistingUser) throw new Error("User does not exist");
|
|
let initiatingSession: any | null = null;
|
|
if (args.userId !== undefined) {
|
|
initiatingSession = args.initiatingSessionHash
|
|
? await findSessionByHash(ctx, args.initiatingSessionHash)
|
|
: null;
|
|
if (!isUsableSession(initiatingSession, timestamp) || initiatingSession.userId !== args.userId) {
|
|
throw new Error("Initiating session is no longer valid");
|
|
}
|
|
}
|
|
|
|
if (args.intent === "upgrade_dataset" || args.intent === "reconnect") {
|
|
if (args.userId === undefined || args.targetCharacterId !== args.characterId) throw new Error("OAuth target is required");
|
|
}
|
|
if (args.intent === "link_character" && args.userId === undefined) throw new Error("Authenticated session is required to link a character");
|
|
|
|
const user = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_user_id", (q: any) => q.eq("userId", resolvedUserId))
|
|
.first();
|
|
if (!user) {
|
|
if (args.userId !== undefined || existingCharacter) throw new Error("User does not exist");
|
|
await ctx.db.insert("users", { userId: resolvedUserId, createdAt: timestamp });
|
|
}
|
|
|
|
let character = existingCharacter;
|
|
if (!character) character = await findCharacter(ctx, resolvedUserId, args.characterId);
|
|
const oldScopes = character?.scopes ?? [];
|
|
const oldRevision = character?.grantRevision ?? 1;
|
|
const grantChanged = character ? !scopesEqual(oldScopes, args.scopes) : false;
|
|
const grantRevision = character ? oldRevision + (grantChanged ? 1 : 0) : 1;
|
|
const authorizationEpoch = character?.authorizationEpoch ?? 1;
|
|
const existingTokenForVersion = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId)).first();
|
|
const credentialVersion = Math.max(character?.credentialVersion ?? 0, existingTokenForVersion?.version ?? 0) + 1;
|
|
const updated = character
|
|
? updateDatasetStatuses(character, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp)
|
|
: updateDatasetStatuses({ state: "never", scopes: [], datasetStatuses: [] }, args.scopes, grantRevision, authorizationEpoch, credentialVersion, timestamp);
|
|
|
|
const basePatch = {
|
|
name: args.name,
|
|
scopes: args.scopes,
|
|
ownerHash: args.ownerHash,
|
|
loginRole: character ? character.loginRole : (args.userId === undefined ? "primary" : "data_only"),
|
|
authorizationStatus: "active" as const,
|
|
grantRevision,
|
|
credentialVersion,
|
|
authorizationEpoch,
|
|
datasetStatuses: updated.statuses,
|
|
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}),
|
|
};
|
|
if (character) {
|
|
await ctx.db.patch(character._id, basePatch);
|
|
} else {
|
|
await ctx.db.insert("characters", {
|
|
userId: resolvedUserId,
|
|
characterId: args.characterId,
|
|
state: "never",
|
|
...basePatch,
|
|
});
|
|
}
|
|
|
|
const tokenRecord = await ctx.db
|
|
.query("tokenRecords")
|
|
.withIndex("by_user_character", (q: any) => q.eq("userId", resolvedUserId).eq("characterId", args.characterId))
|
|
.first();
|
|
if (tokenRecord) {
|
|
await ctx.db.patch(tokenRecord._id, {
|
|
encryptedTokens: args.encryptedTokens,
|
|
tokenExpiresAt: args.tokenExpiresAt,
|
|
scopes: args.scopes,
|
|
version: credentialVersion,
|
|
});
|
|
} else {
|
|
await ctx.db.insert("tokenRecords", {
|
|
userId: resolvedUserId,
|
|
characterId: args.characterId,
|
|
encryptedTokens: args.encryptedTokens,
|
|
tokenExpiresAt: args.tokenExpiresAt,
|
|
scopes: args.scopes,
|
|
version: credentialVersion,
|
|
});
|
|
}
|
|
if (character && (grantChanged || updated.assetsInvalidated || credentialVersion !== character.credentialVersion)) await invalidateCharacterJobs(ctx, resolvedUserId, args.characterId, timestamp);
|
|
|
|
const sessionId = newIdentifier("session");
|
|
await ctx.db.insert("sessions", {
|
|
sessionHash: args.sessionHash,
|
|
sessionId,
|
|
userId: resolvedUserId,
|
|
expiresAt: args.sessionExpiresAt,
|
|
});
|
|
// Replacement is atomic with persistence; the callback never has to
|
|
// replay a code if cookie delivery later fails.
|
|
if (initiatingSession && !initiatingSession.revokedAt) await ctx.db.patch(initiatingSession._id, { revokedAt: timestamp });
|
|
return { userId: resolvedUserId, sessionId };
|
|
},
|
|
});
|
|
|
|
export const bridgeTokenRecord = queryGeneric({
|
|
args: { ...bridgeArgs, ...characterArgs },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character || character.authorizationStatus !== "active") return null;
|
|
return await ctx.db
|
|
.query("tokenRecords")
|
|
.withIndex("by_user_character", (q: any) =>
|
|
q.eq("userId", args.userId).eq("characterId", args.characterId),
|
|
)
|
|
.first()
|
|
.then((record: any) =>
|
|
record
|
|
? {
|
|
encryptedTokens: record.encryptedTokens,
|
|
tokenExpiresAt: record.tokenExpiresAt,
|
|
version: record.version,
|
|
scopes: record.scopes,
|
|
}
|
|
: null,
|
|
);
|
|
},
|
|
});
|
|
|
|
export const bridgeSaveTokens = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
...characterArgs,
|
|
encryptedTokens: v.string(),
|
|
tokenExpiresAt: v.number(),
|
|
scopes: v.array(v.string()),
|
|
expectedVersion: v.number(),
|
|
returnAuthority: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
if (!validActualScopes(args.scopes)) return false;
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character || character.authorizationStatus !== "active" || !scopesSubset(args.scopes, character.scopes ?? [])) return false;
|
|
const record = await ctx.db
|
|
.query("tokenRecords")
|
|
.withIndex("by_user_character", (q: any) =>
|
|
q.eq("userId", args.userId).eq("characterId", args.characterId),
|
|
)
|
|
.first();
|
|
if (!record || record.version !== args.expectedVersion) return false;
|
|
const nextVersion = record.version + 1;
|
|
const nextRevision = character.grantRevision + (scopesEqual(character.scopes, args.scopes) ? 0 : 1);
|
|
const updated = updateDatasetStatuses(character, args.scopes, nextRevision, character.authorizationEpoch, nextVersion, now());
|
|
await ctx.db.patch(record._id, {
|
|
encryptedTokens: args.encryptedTokens,
|
|
tokenExpiresAt: args.tokenExpiresAt,
|
|
scopes: args.scopes,
|
|
version: nextVersion,
|
|
});
|
|
await ctx.db.patch(character._id, {
|
|
scopes: args.scopes,
|
|
grantRevision: nextRevision,
|
|
credentialVersion: nextVersion,
|
|
datasetStatuses: updated.statuses,
|
|
...(updated.assetsInvalidated ? { state: "error" as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}),
|
|
});
|
|
if (updated.assetsInvalidated || nextRevision !== character.grantRevision || nextVersion !== character.credentialVersion) await invalidateCharacterJobs(ctx, args.userId, args.characterId, now());
|
|
if (args.returnAuthority) {
|
|
return {
|
|
saved: true,
|
|
credentialVersion: nextVersion,
|
|
grantRevision: nextRevision,
|
|
authorizationEpoch: character.authorizationEpoch,
|
|
scopes: args.scopes,
|
|
};
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const bridgeCharacters = queryGeneric({
|
|
args: { ...bridgeArgs, userId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
return await ctx.db
|
|
.query("characters")
|
|
.withIndex("by_user_id", (q: any) => q.eq("userId", args.userId))
|
|
.collect();
|
|
},
|
|
});
|
|
|
|
/** Change the one signed-out login character without changing authorization. */
|
|
export const bridgeSetPrimary = mutationGeneric({
|
|
args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
|
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid");
|
|
const target = await findCharacter(ctx, args.userId, args.characterId);
|
|
const targetToken = target
|
|
? await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first()
|
|
: null;
|
|
if (!target || !targetToken || target.authorizationStatus !== "active") {
|
|
throw new Error("Character is not eligible to be primary");
|
|
}
|
|
const characters = await ctx.db.query("characters").withIndex("by_user_id", (q: any) => q.eq("userId", args.userId)).collect();
|
|
for (const character of characters) {
|
|
if (character._id === target._id) {
|
|
await ctx.db.patch(character._id, { loginRole: "primary" });
|
|
} else if (character.loginRole === "primary" || character.loginRole === undefined) {
|
|
await ctx.db.patch(character._id, { loginRole: "data_only" });
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
/** Local cancellation immediately fences private data and future work. */
|
|
export const bridgeQuarantineAuthorization = mutationGeneric({
|
|
args: { ...bridgeArgs, userId: v.string(), characterId: v.number() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (character) await quarantineCharacter(ctx, character, "Character ownership changed; reconnect required");
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeInvalidateAuthorization = mutationGeneric({
|
|
args: { ...bridgeArgs, userId: v.string(), characterId: v.number(), initiatingSessionHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
|
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error("Session is no longer valid");
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character) throw new Error("Character not found");
|
|
if (character.loginRole === "primary") throw new Error("Switch the primary character before cancelling its authorization");
|
|
const timestamp = now();
|
|
const epoch = character.authorizationEpoch + 1;
|
|
const statuses = DATASET_NAMES.map((dataset) => ({
|
|
dataset,
|
|
status: "blocked" as const,
|
|
grantRevision: character.grantRevision,
|
|
authorizationEpoch: epoch,
|
|
credentialVersion: character.credentialVersion,
|
|
invalidatedAt: timestamp,
|
|
}));
|
|
await ctx.db.patch(character._id, {
|
|
authorizationStatus: "revoked",
|
|
authorizationEpoch: epoch,
|
|
datasetStatuses: statuses,
|
|
state: "error",
|
|
activeGeneration: undefined,
|
|
syncGeneration: undefined,
|
|
syncLeaseExpiresAt: undefined,
|
|
error: "Authorization cancelled",
|
|
});
|
|
const token = await ctx.db.query("tokenRecords").withIndex("by_user_character", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId)).first();
|
|
if (token) await ctx.db.delete(token._id);
|
|
const jobs = await ctx.db.query("jobs").withIndex("by_user_status", (q: any) => q.eq("userId", args.userId)).collect();
|
|
for (const job of jobs) {
|
|
if (job.characterId === args.characterId && !["succeeded", "failed", "cancelled"].includes(job.status)) {
|
|
await ctx.db.patch(job._id, { status: "quarantined", updatedAt: timestamp, leaseExpiresAt: undefined });
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Read an active snapshot one page of chunks at a time. The application adapter
|
|
* walks this query's cursor, rather than issuing an unbounded Convex collect.
|
|
*/
|
|
export const bridgeAssetsPage = queryGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
...characterArgs,
|
|
paginationOpts: paginationOptsValidator,
|
|
expectedGeneration: v.optional(v.string()),
|
|
},
|
|
returns: assetPageResultValidator,
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === "assets");
|
|
const authorized = character && character.authorizationStatus === "active"
|
|
&& character.scopes.includes("esi-assets.read_assets.v1")
|
|
&& assetStatus
|
|
&& (assetStatus.status === "ready" || assetStatus.status === "error")
|
|
&& assetStatus.grantRevision === character.grantRevision
|
|
&& assetStatus.authorizationEpoch === character.authorizationEpoch;
|
|
const generation = authorized ? character.activeGeneration ?? null : null;
|
|
if (!character || !generation) {
|
|
return { page: [], isDone: true, continueCursor: "", generation: null, enrichmentStatus: null, enrichmentVersion: null, snapshotChanged: false };
|
|
}
|
|
// The caller pins the first page's generation. Returning the new generation
|
|
// without its data prevents a read from silently mixing two snapshots.
|
|
const generationRecord = await ctx.db.query("assetGenerations").withIndex("by_user_character_generation", (q: any) => q.eq("userId", args.userId).eq("characterId", args.characterId).eq("generation", generation)).first();
|
|
const enrichmentVisible = generationRecord ? enrichmentAuthorityMatches(character, generationRecord) : false;
|
|
if (args.expectedGeneration !== undefined && args.expectedGeneration !== generation) {
|
|
return { page: [], isDone: true, continueCursor: "", generation, enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null, enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null, snapshotChanged: true };
|
|
}
|
|
const page = await ctx.db
|
|
.query("assetChunks")
|
|
.withIndex("by_snapshot_index", (q: any) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("characterId", args.characterId)
|
|
.eq("generation", generation),
|
|
)
|
|
.order("asc")
|
|
.paginate(args.paginationOpts);
|
|
return {
|
|
page: page.page.flatMap((chunk: any) => chunk.assets),
|
|
isDone: page.isDone,
|
|
continueCursor: page.continueCursor,
|
|
generation,
|
|
enrichmentStatus: enrichmentVisible ? generationRecord?.enrichmentStatus ?? null : null,
|
|
enrichmentVersion: enrichmentVisible ? generationRecord?.enrichmentVersion ?? null : null,
|
|
snapshotChanged: false,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const bridgeEnrichmentManifest = queryGeneric({
|
|
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.optional(v.string()) },
|
|
returns: v.union(enrichmentManifestValidator, v.null()),
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
const assetStatus = character?.datasetStatuses?.find((entry: any) => entry.dataset === 'assets');
|
|
if (!character || character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1') || !assetStatus || (assetStatus.status !== 'ready' && assetStatus.status !== 'error') || assetStatus.grantRevision !== character.grantRevision || assetStatus.authorizationEpoch !== character.authorizationEpoch) return null;
|
|
const generation = character.activeGeneration;
|
|
if (!generation || (args.expectedGeneration !== undefined && args.expectedGeneration !== generation)) return null;
|
|
const record = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', generation)).first();
|
|
if (!record || !enrichmentAuthorityMatches(character, record)) return null;
|
|
return { generation, status: record.enrichmentStatus ?? null, version: record.enrichmentVersion ?? null, inputPin: record.enrichmentInputPin ?? null, unresolvedCount: record.enrichmentUnresolvedCount ?? null };
|
|
},
|
|
});
|
|
|
|
export const bridgeEnrichmentChunksPage = queryGeneric({
|
|
args: { ...bridgeArgs, ...characterArgs, expectedGeneration: v.string(), paginationOpts: paginationOptsValidator },
|
|
returns: enrichmentPageValidator,
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
const authorized = character?.authorizationStatus === 'active' && character.scopes.includes('esi-assets.read_assets.v1');
|
|
const activeGeneration = authorized ? character?.activeGeneration ?? null : null;
|
|
if (!character || activeGeneration !== args.expectedGeneration) return { chunks: [], isDone: true, continueCursor: '', generation: activeGeneration, status: null, version: null };
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', activeGeneration)).first();
|
|
if (!generation || generation.status !== 'complete' || !enrichmentAuthorityMatches(character, generation)) return { chunks: [], isDone: true, continueCursor: '', generation: null, status: null, version: null };
|
|
const page = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId).eq('generation', args.expectedGeneration)).order('asc').paginate(args.paginationOpts);
|
|
return { chunks: page.page.map((chunk: any) => ({ index: chunk.index, rows: chunk.rows })), isDone: page.isDone, continueCursor: page.continueCursor, generation: args.expectedGeneration, status: generation.enrichmentStatus ?? null, version: generation.enrichmentVersion ?? null };
|
|
},
|
|
});
|
|
|
|
export const bridgeBeginSync = mutationGeneric({
|
|
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');
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character) throw new Error("Character not found");
|
|
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");
|
|
}
|
|
const generation = newIdentifier("generation");
|
|
await ctx.db.insert("assetGenerations", {
|
|
userId: args.userId,
|
|
characterId: args.characterId,
|
|
generation,
|
|
status: "staging",
|
|
stagedChunkCount: 0,
|
|
grantRevision: character.grantRevision,
|
|
authorizationEpoch: character.authorizationEpoch,
|
|
credentialVersion: character.credentialVersion,
|
|
});
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
|
? { ...entry, status: "syncing", grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch }
|
|
: entry);
|
|
await ctx.db.patch(character._id, {
|
|
state: "syncing",
|
|
datasetStatuses: statuses,
|
|
syncGeneration: generation,
|
|
syncLeaseExpiresAt: timestamp + SYNC_LEASE_MS,
|
|
error: undefined,
|
|
});
|
|
return generation;
|
|
},
|
|
});
|
|
|
|
export const bridgeStageAssets = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
...characterArgs,
|
|
generation: v.string(),
|
|
index: v.number(),
|
|
assets: v.array(assetValidator),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
assertDeploymentCapability('asset_sync');
|
|
if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) {
|
|
throw new Error("Invalid asset chunk");
|
|
}
|
|
if (args.assets.some((asset: any) => asset.characterId !== args.characterId)) {
|
|
throw new Error("Asset character mismatch");
|
|
}
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (
|
|
!character ||
|
|
character.syncGeneration !== args.generation ||
|
|
(character.syncLeaseExpiresAt ?? 0) <= now()
|
|
) {
|
|
throw new Error("Synchronization lease is not valid");
|
|
}
|
|
const generation = await ctx.db
|
|
.query("assetGenerations")
|
|
.withIndex("by_user_character_generation", (q: any) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("characterId", args.characterId)
|
|
.eq("generation", args.generation),
|
|
)
|
|
.first();
|
|
if (!generation || generation.status !== "staging") throw new Error("Snapshot is not staging");
|
|
|
|
const stagedChunkCount = generation.stagedChunkCount ?? 0;
|
|
if (stagedChunkCount >= MAX_CHUNKS || args.index !== stagedChunkCount) {
|
|
throw new Error("Asset chunks must be staged exactly once and in order");
|
|
}
|
|
const existing = await ctx.db
|
|
.query("assetChunks")
|
|
.withIndex("by_snapshot_index", (q: any) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("characterId", args.characterId)
|
|
.eq("generation", args.generation)
|
|
.eq("index", args.index),
|
|
)
|
|
.first();
|
|
if (existing) throw new Error("Asset chunk already exists");
|
|
// Insert and counter advancement are one mutation. A failed insert rolls
|
|
// back the counter, while a duplicate/out-of-order index is rejected.
|
|
await ctx.db.insert("assetChunks", {
|
|
userId: args.userId,
|
|
characterId: args.characterId,
|
|
generation: args.generation,
|
|
index: args.index,
|
|
assets: args.assets,
|
|
});
|
|
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1 });
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeFinishSync = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
...characterArgs,
|
|
generation: v.string(),
|
|
chunkCount: v.number(),
|
|
syncedAt: v.number(),
|
|
expiresAt: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
assertDeploymentCapability('asset_sync');
|
|
const timestamp = now();
|
|
if (
|
|
!Number.isFinite(args.syncedAt) ||
|
|
args.syncedAt < 0 ||
|
|
args.syncedAt > timestamp ||
|
|
!Number.isFinite(args.expiresAt) ||
|
|
args.expiresAt <= timestamp ||
|
|
args.expiresAt <= args.syncedAt ||
|
|
args.expiresAt - args.syncedAt > SNAPSHOT_MAX_LIFETIME_MS
|
|
) {
|
|
throw new Error("Invalid snapshot timestamps");
|
|
}
|
|
if (!Number.isInteger(args.chunkCount) || args.chunkCount < 0 || args.chunkCount > MAX_CHUNKS) {
|
|
throw new Error("Invalid snapshot chunk count");
|
|
}
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (
|
|
!character ||
|
|
character.syncGeneration !== args.generation ||
|
|
(character.syncLeaseExpiresAt ?? 0) <= now()
|
|
) {
|
|
throw new Error("Synchronization lease is not valid");
|
|
}
|
|
const generation = await ctx.db
|
|
.query("assetGenerations")
|
|
.withIndex("by_user_character_generation", (q: any) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("characterId", args.characterId)
|
|
.eq("generation", args.generation),
|
|
)
|
|
.first();
|
|
if (!generation || generation.status !== "staging") throw new Error("Snapshot is not staging");
|
|
|
|
// Staging maintains a contiguous durable counter, so publication can
|
|
// validate completeness without reading every asset payload.
|
|
if ((generation.stagedChunkCount ?? 0) !== args.chunkCount) {
|
|
throw new Error("Snapshot is missing chunks");
|
|
}
|
|
|
|
if (generation.grantRevision !== character.grantRevision
|
|
|| generation.authorizationEpoch !== character.authorizationEpoch
|
|
|| generation.credentialVersion !== character.credentialVersion
|
|
|| character.authorizationStatus !== "active") {
|
|
throw new Error("Synchronization authorization is stale");
|
|
}
|
|
await ctx.db.patch(generation._id, {
|
|
status: "complete",
|
|
chunkCount: args.chunkCount,
|
|
syncedAt: args.syncedAt,
|
|
expiresAt: args.expiresAt,
|
|
});
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
|
? { ...entry, status: "ready", generation: args.generation, syncedAt: args.syncedAt, error: undefined }
|
|
: entry);
|
|
await ctx.db.patch(character._id, {
|
|
state: "ready",
|
|
datasetStatuses: statuses,
|
|
syncedAt: args.syncedAt,
|
|
expiresAt: args.expiresAt,
|
|
activeGeneration: args.generation,
|
|
syncGeneration: undefined,
|
|
syncLeaseExpiresAt: undefined,
|
|
error: undefined,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeFailSync = mutationGeneric({
|
|
args: { ...bridgeArgs, ...characterArgs, generation: v.string(), error: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
assertDeploymentCapability('asset_sync');
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character || character.syncGeneration !== args.generation) {
|
|
throw new Error("Synchronization generation is not current");
|
|
}
|
|
const generation = await ctx.db
|
|
.query("assetGenerations")
|
|
.withIndex("by_user_character_generation", (q: any) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("characterId", args.characterId)
|
|
.eq("generation", args.generation),
|
|
)
|
|
.first();
|
|
if (!generation || generation.status !== "staging"
|
|
|| (character.syncLeaseExpiresAt ?? 0) <= now()
|
|
|| generation.grantRevision !== character.grantRevision
|
|
|| generation.authorizationEpoch !== character.authorizationEpoch
|
|
|| generation.credentialVersion !== character.credentialVersion
|
|
|| character.authorizationStatus !== "active") {
|
|
throw new Error("Synchronization failure publication is stale");
|
|
}
|
|
await ctx.db.patch(generation._id, {
|
|
status: "failed",
|
|
error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH),
|
|
});
|
|
// Deliberately do not touch activeGeneration: the previous complete
|
|
// snapshot remains available after a failed synchronization.
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === "assets"
|
|
? { ...entry, status: character.activeGeneration ? "error" : "blocked", error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH) }
|
|
: entry);
|
|
await ctx.db.patch(character._id, {
|
|
state: "error",
|
|
datasetStatuses: statuses,
|
|
error: safeSyncError(args.error).slice(0, MAX_ERROR_LENGTH),
|
|
syncGeneration: undefined,
|
|
syncLeaseExpiresAt: undefined,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
/** Return only fields safe for an authenticated status response. */
|
|
function safeSyncJob(job: any): any {
|
|
const knownErrors = new Set(['Synchronization cancelled', 'Synchronization scheduled for retry', 'Authorization is no longer active', 'Asset synchronization failed']);
|
|
const error = typeof job.lastError === 'string' && knownErrors.has(job.lastError) ? job.lastError : undefined;
|
|
return {
|
|
id: job._id,
|
|
dataset: job.dataset ?? 'assets',
|
|
characterId: job.characterId,
|
|
status: job.status,
|
|
attempts: job.attempts ?? 0,
|
|
dueAt: job.dueAt ?? job.createdAt,
|
|
createdAt: job.createdAt,
|
|
updatedAt: job.updatedAt,
|
|
leaseExpiresAt: job.status === 'running' ? (job.leaseExpiresAt ?? null) : null,
|
|
cancelRequested: job.cancelRequested === true,
|
|
...(error === undefined ? {} : { error }),
|
|
};
|
|
}
|
|
|
|
function safeJobError(): string { return 'Asset synchronization failed'; }
|
|
function activeJobStatus(status: string): boolean { return status === 'queued' || status === 'running'; }
|
|
|
|
async function findJob(ctx: any, jobId: string): Promise<any | null> {
|
|
return await ctx.db.get(jobId as any);
|
|
}
|
|
|
|
async function markTerminalSyncFailure(ctx: any, job: any, error = safeSyncError('terminal failure')): Promise<void> {
|
|
if (job.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error });
|
|
}
|
|
const character = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (!character) return;
|
|
const assetWasSyncing = character.syncGeneration === job.generation || character.datasetStatuses?.some((entry: any) => entry.dataset === 'assets' && (entry.status === 'syncing' || entry.status === 'queued'));
|
|
if (!assetWasSyncing) return;
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
|
|
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error }
|
|
: entry);
|
|
// Never clear activeGeneration/syncedAt/expiresAt: the last complete snapshot
|
|
// remains the only readable publication until a replacement succeeds.
|
|
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error });
|
|
}
|
|
|
|
async function clearMatchingSyncPointer(ctx: any, character: any, generation: string, error = 'Authorization is no longer active'): Promise<boolean> {
|
|
if (!character || character.syncGeneration !== generation) return false;
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
|
|
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error, generation: character.activeGeneration ?? undefined }
|
|
: entry);
|
|
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error });
|
|
return true;
|
|
}
|
|
|
|
async function invalidateCharacterJobs(ctx: any, userId: string, characterId: number, timestamp: number): Promise<void> {
|
|
const character = await findCharacter(ctx, userId, characterId);
|
|
const jobs = await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', userId)).collect();
|
|
let staleSyncState = false;
|
|
for (const job of jobs) {
|
|
if (workerJob(job) && job.characterId === characterId && activeJobStatus(job.status)) {
|
|
if (job.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', userId).eq('characterId', characterId).eq('generation', job.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
|
|
}
|
|
if (character && job.generation !== undefined && character.syncGeneration === job.generation) staleSyncState = true;
|
|
await ctx.db.patch(job._id, {
|
|
status: 'quarantined', updatedAt: timestamp, leaseExpiresAt: undefined,
|
|
leaseToken: undefined, workerId: undefined, cancelRequested: undefined,
|
|
lastError: 'Authorization is no longer active', finishedAt: timestamp,
|
|
});
|
|
}
|
|
}
|
|
if (character && staleSyncState && character.syncGeneration !== undefined) await clearMatchingSyncPointer(ctx, character, character.syncGeneration);
|
|
}
|
|
|
|
/** Server-derived enqueue. The caller supplies a session proof, never a job authority. */
|
|
export const bridgeEnqueueAssetSync = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
userId: v.string(),
|
|
characterId: v.number(),
|
|
initiatingSessionHash: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
assertDeploymentCapability('asset_sync');
|
|
const timestamp = now();
|
|
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
|
if (!isUsableSession(initiating, timestamp) || initiating.userId !== args.userId) throw new Error('Session is no longer valid');
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character || character.authorizationStatus !== 'active' || !character.scopes.includes('esi-assets.read_assets.v1')) {
|
|
throw new Error('Asset authorization is not active');
|
|
}
|
|
const dedupeKey = `${args.userId}:character:${args.characterId}:assets`;
|
|
const existing = await ctx.db.query('jobs').withIndex('by_dedupe', (q: any) => q.eq('dedupeKey', dedupeKey)).collect();
|
|
const active = existing.find((job: any) => activeJobStatus(job.status));
|
|
if (active) return safeSyncJob(active);
|
|
const queuedJobs = (await ctx.db.query('jobs').withIndex('by_user_asset_status', (q: any) => q.eq('userId', args.userId).eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'queued')).take(MAX_USER_ACTIVE_SYNC_JOBS + 1)).filter((job: any) => workerJob(job));
|
|
const runningJobs = (await ctx.db.query('jobs').withIndex('by_user_asset_status', (q: any) => q.eq('userId', args.userId).eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'running')).take(MAX_USER_ACTIVE_SYNC_JOBS + 1)).filter((job: any) => workerJob(job));
|
|
if (queuedJobs.length + runningJobs.length >= MAX_USER_ACTIVE_SYNC_JOBS) throw new Error('Synchronization queue is full');
|
|
const maxAttempts = 5;
|
|
const job = await ctx.db.insert('jobs', {
|
|
userId: args.userId,
|
|
kind: 'asset_sync',
|
|
dataset: 'assets',
|
|
status: 'queued',
|
|
characterId: args.characterId,
|
|
dedupeKey,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
dueAt: timestamp,
|
|
attempts: 0,
|
|
maxAttempts,
|
|
priority: 0,
|
|
ownerKind: 'user',
|
|
authority: 'user',
|
|
grantRevision: character.grantRevision,
|
|
authorizationEpoch: character.authorizationEpoch,
|
|
credentialVersion: character.credentialVersion,
|
|
scopes: [...character.scopes],
|
|
cooldownGroup: SYNC_PROVIDER_GROUP,
|
|
cancelRequested: false,
|
|
});
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
|
|
? { ...entry, status: 'queued', grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion, error: undefined }
|
|
: entry);
|
|
await ctx.db.patch(character._id, { state: 'queued', datasetStatuses: statuses, error: undefined });
|
|
return safeSyncJob({ _id: job, dataset: 'assets', characterId: args.characterId, status: 'queued', attempts: 0, dueAt: timestamp, createdAt: timestamp, updatedAt: timestamp, cancelRequested: false });
|
|
},
|
|
});
|
|
|
|
export const bridgeSyncJob = queryGeneric({
|
|
args: { ...bridgeArgs, userId: v.string(), jobId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const job = await findJob(ctx, args.jobId);
|
|
if (!job || job.userId !== args.userId || job.kind !== 'asset_sync') return null;
|
|
return safeSyncJob(job);
|
|
},
|
|
});
|
|
|
|
/** Cancellation is authenticated against the initiating session and fences a running lease immediately. */
|
|
export const bridgeCancelSyncJob = mutationGeneric({
|
|
args: { ...bridgeArgs, userId: v.string(), jobId: v.string(), initiatingSessionHash: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
assertDeploymentCapability('asset_sync');
|
|
const initiating = await findSessionByHash(ctx, args.initiatingSessionHash);
|
|
if (!isUsableSession(initiating) || initiating.userId !== args.userId) throw new Error('Session is no longer valid');
|
|
const job = await findJob(ctx, args.jobId);
|
|
if (!job || job.userId !== args.userId || job.kind !== 'asset_sync') return null;
|
|
if (!activeJobStatus(job.status)) return safeSyncJob(job);
|
|
const timestamp = now();
|
|
const character = job.characterId === undefined ? null : await findCharacter(ctx, args.userId, job.characterId);
|
|
if (job.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', args.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Synchronization cancelled' });
|
|
}
|
|
await ctx.db.patch(job._id, {
|
|
status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp,
|
|
leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined,
|
|
cancelRequested: true, lastError: 'Synchronization cancelled',
|
|
});
|
|
if (character && character.syncGeneration === job.generation) {
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets'
|
|
? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: 'Synchronization cancelled' }
|
|
: entry);
|
|
await ctx.db.patch(character._id, { state: character.activeGeneration ? 'error' : 'error', datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: 'Synchronization cancelled' });
|
|
} else if (character && character.datasetStatuses?.some((entry: any) => entry.dataset === 'assets' && entry.status === 'queued')) {
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: 'Synchronization cancelled' } : entry);
|
|
await ctx.db.patch(character._id, { state: character.activeGeneration ? 'error' : 'error', datasetStatuses: statuses, error: 'Synchronization cancelled' });
|
|
}
|
|
return safeSyncJob({ ...job, status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, cancelRequested: true, lastError: 'Synchronization cancelled' });
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Claim and recover in one transaction. Expired leases are requeued before a
|
|
* candidate is selected, making process termination recoverable without a
|
|
* second scheduler or an in-memory queue.
|
|
*/
|
|
export const bridgeClaimSyncJob = mutationGeneric({
|
|
args: { ...workerBridgeArgs, workerId: v.string(), leaseMs: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
if (!args.workerId || args.workerId.length > 160) throw new Error('Invalid worker identity');
|
|
const timestamp = now();
|
|
const leaseMs = args.leaseMs ?? 10 * 60 * 1000;
|
|
if (!Number.isSafeInteger(leaseMs) || leaseMs < 60_000 || leaseMs > 60 * 60 * 1000) throw new Error('Invalid sync lease duration');
|
|
const expired = await ctx.db.query('jobs').withIndex('by_lease_expires_at', (q: any) => q.lte('leaseExpiresAt', timestamp)).take(100);
|
|
for (const job of expired) {
|
|
if (!workerJob(job) || job.status !== 'running' || (job.leaseExpiresAt ?? Infinity) > timestamp) continue;
|
|
if (job.cancelRequested === true) {
|
|
await ctx.db.patch(job._id, { status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Synchronization cancelled' });
|
|
} else if ((job.attempts ?? 0) >= (job.maxAttempts ?? 5)) {
|
|
const character = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (!authorityMatches(character, job)) {
|
|
if (job.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
|
|
await clearMatchingSyncPointer(ctx, character, job.generation);
|
|
}
|
|
await ctx.db.patch(job._id, { status: 'quarantined', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Authorization is no longer active' });
|
|
continue;
|
|
}
|
|
await markTerminalSyncFailure(ctx, job);
|
|
await ctx.db.patch(job._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
|
|
} else {
|
|
await ctx.db.patch(job._id, { status: 'queued', updatedAt: timestamp, dueAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined });
|
|
}
|
|
}
|
|
const candidates = await ctx.db.query('jobs').withIndex('by_asset_sync_queue', (q: any) => q.eq('kind', 'asset_sync').eq('dataset', 'assets').eq('authority', 'user').eq('ownerKind', 'user').eq('status', 'queued').lte('dueAt', timestamp)).order('asc').take(100);
|
|
if (candidates.length === 0) return null;
|
|
// Bounded fair selection: an account with an in-flight job loses to an
|
|
// account with no in-flight work; due/creation order breaks ties.
|
|
const supportedCandidates = candidates.filter((candidate: any) => workerJob(candidate));
|
|
if (supportedCandidates.length === 0) return null;
|
|
const runningByUser = new Map<string, number>();
|
|
for (const candidate of supportedCandidates) {
|
|
if (runningByUser.has(candidate.userId)) continue;
|
|
const running = (await ctx.db.query('jobs').withIndex('by_user_status', (q: any) => q.eq('userId', candidate.userId).eq('status', 'running')).take(20)).filter((job: any) => workerJob(job));
|
|
runningByUser.set(candidate.userId, running.length);
|
|
}
|
|
const ordered = [...supportedCandidates].sort((left: any, right: any) =>
|
|
(runningByUser.get(left.userId)! - runningByUser.get(right.userId)!)
|
|
|| ((left.priority ?? 0) - (right.priority ?? 0))
|
|
|| ((left.dueAt ?? left.createdAt) - (right.dueAt ?? right.createdAt))
|
|
|| ((left.lastClaimedAt ?? 0) - (right.lastClaimedAt ?? 0))
|
|
|| String(left._id).localeCompare(String(right._id)));
|
|
for (const candidate of ordered) {
|
|
if (candidate.cancelRequested === true) {
|
|
await ctx.db.patch(candidate._id, { status: 'cancelled', updatedAt: timestamp, finishedAt: timestamp, lastError: 'Synchronization cancelled' });
|
|
continue;
|
|
}
|
|
if (candidate.cooldownGroup) {
|
|
const shared = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', `group:${candidate.cooldownGroup}`)).first();
|
|
if (shared && shared.until > timestamp) continue;
|
|
}
|
|
const character = candidate.characterId === undefined ? null : await findCharacter(ctx, candidate.userId, candidate.characterId);
|
|
const authorityCurrent = authorityMatches(character, candidate);
|
|
if (!authorityCurrent) {
|
|
if (candidate.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', candidate.userId).eq('characterId', candidate.characterId).eq('generation', candidate.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization is no longer active' });
|
|
await clearMatchingSyncPointer(ctx, character, candidate.generation);
|
|
}
|
|
await ctx.db.patch(candidate._id, { status: 'quarantined', updatedAt: timestamp, finishedAt: timestamp, lastError: 'Authorization is no longer active' });
|
|
continue;
|
|
}
|
|
const leaseToken = newIdentifier('lease');
|
|
const attempts = (candidate.attempts ?? 0) + 1;
|
|
await ctx.db.patch(candidate._id, { status: 'running', attempts, updatedAt: timestamp, startedAt: candidate.startedAt ?? timestamp, dueAt: timestamp, leaseExpiresAt: timestamp + leaseMs, leaseToken, workerId: args.workerId, lastClaimedAt: timestamp });
|
|
return { ...safeSyncJob({ ...candidate, status: 'running', attempts, updatedAt: timestamp, leaseExpiresAt: timestamp + leaseMs, leaseToken }), jobId: candidate._id, leaseToken, userId: candidate.userId, characterId: candidate.characterId, grantRevision: candidate.grantRevision, authorizationEpoch: candidate.authorizationEpoch, credentialVersion: candidate.credentialVersion, scopes: candidate.scopes ?? [], ...(candidate.generation === undefined ? {} : { generation: candidate.generation }), cooldownGroup: candidate.cooldownGroup ?? SYNC_PROVIDER_GROUP };
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeRenewSyncJob = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), workerId: v.string(), leaseMs: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await findJob(ctx, args.jobId);
|
|
if (!job || !workerJob(job) || job.status !== 'running' || job.leaseToken !== args.leaseToken || job.workerId !== args.workerId) return false;
|
|
if (job.cancelRequested === true || (job.leaseExpiresAt ?? 0) <= now()) return false;
|
|
const leaseMs = args.leaseMs ?? 10 * 60 * 1000;
|
|
if (!Number.isSafeInteger(leaseMs) || leaseMs < 60_000 || leaseMs > 60 * 60 * 1000) return false;
|
|
await ctx.db.patch(job._id, { leaseExpiresAt: now() + leaseMs, updatedAt: now() });
|
|
return true;
|
|
},
|
|
});
|
|
|
|
async function jobLease(ctx: any, jobId: string, leaseToken: string): Promise<any> {
|
|
const job = await findJob(ctx, jobId);
|
|
if (!job || !workerJob(job) || job.status !== 'running' || job.leaseToken !== leaseToken || (job.leaseExpiresAt ?? 0) <= now()) throw new Error('Synchronization lease is not valid');
|
|
return job;
|
|
}
|
|
|
|
async function jobCharacter(ctx: any, job: any): Promise<any> {
|
|
const character = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (!authorityMatches(character, job)) {
|
|
throw new Error('Synchronization authorization is stale');
|
|
}
|
|
return character;
|
|
}
|
|
|
|
export const bridgeRefreshSyncJobAuthority = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), expectedGrantRevision: v.number(), expectedAuthorizationEpoch: v.number(), expectedCredentialVersion: v.number(), expectedScopes: v.array(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await findCharacter(ctx, job.userId, job.characterId);
|
|
const expected = { grantRevision: args.expectedGrantRevision, authorizationEpoch: args.expectedAuthorizationEpoch, credentialVersion: args.expectedCredentialVersion, scopes: args.expectedScopes };
|
|
if (!authorityMatches(character, expected)) throw new Error('Synchronization authorization changed');
|
|
if (!authorityMatches(character, job)) throw new Error('Synchronization job authority is stale');
|
|
return expected;
|
|
},
|
|
});
|
|
|
|
export const bridgeRestartSyncJobGeneration = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
if (job.generation === undefined) return null;
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: 'Authorization refreshed before publication' });
|
|
const character = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (character?.syncGeneration === job.generation) await ctx.db.patch(character._id, { syncGeneration: undefined, syncLeaseExpiresAt: undefined, state: 'queued' });
|
|
await ctx.db.patch(job._id, { generation: undefined, updatedAt: now() });
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeBeginSyncJob = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
let character = await jobCharacter(ctx, job);
|
|
if (job.generation !== undefined) {
|
|
const existing = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (existing && existing.status === 'staging' && existing.grantRevision === job.grantRevision && existing.authorizationEpoch === job.authorizationEpoch && existing.credentialVersion === job.credentialVersion) return job.generation;
|
|
if (existing?.status === 'staging') await ctx.db.patch(existing._id, { status: 'failed', error: 'Synchronization authority changed' });
|
|
await ctx.db.patch(job._id, { generation: undefined, updatedAt: now() });
|
|
const oldCharacter = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (oldCharacter?.syncGeneration === job.generation) await ctx.db.patch(oldCharacter._id, { syncGeneration: undefined, syncLeaseExpiresAt: undefined });
|
|
// The character read above predates the atomic cleanup. Re-read it before
|
|
// checking the lease, otherwise a reclaimed job sees its own old pointer
|
|
// and incorrectly reports an active synchronization.
|
|
character = await findCharacter(ctx, job.userId, job.characterId);
|
|
if (!character || !authorityMatches(character, job)) throw new Error('Synchronization authorization is stale');
|
|
}
|
|
if (character.syncGeneration && (character.syncLeaseExpiresAt ?? 0) > now()) throw new Error('Character synchronization is already leased');
|
|
const generation = newIdentifier('generation');
|
|
await ctx.db.insert('assetGenerations', { userId: job.userId, characterId: job.characterId, generation, status: 'staging', stagedChunkCount: 0, stagedAssetRowCount: 0, enrichmentRowCount: 0, grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion });
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'syncing', grantRevision: job.grantRevision, authorizationEpoch: job.authorizationEpoch, credentialVersion: job.credentialVersion, error: undefined } : entry);
|
|
await ctx.db.patch(character._id, { state: 'syncing', datasetStatuses: statuses, syncGeneration: generation, syncLeaseExpiresAt: now() + 10 * 60 * 1000, error: undefined });
|
|
await ctx.db.patch(job._id, { generation, updatedAt: now() });
|
|
return generation;
|
|
},
|
|
});
|
|
|
|
export const bridgeStageSyncAssets = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), index: v.number(), assets: v.array(assetValidator) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
if (!Number.isInteger(args.index) || args.index < 0 || args.assets.length > MAX_ASSET_CHUNK) throw new Error('Invalid asset chunk');
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await jobCharacter(ctx, job);
|
|
if (args.assets.some((asset: any) => asset.characterId !== job.characterId)) throw new Error('Asset character mismatch');
|
|
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
|
|
const stagedChunkCount = generation.stagedChunkCount ?? 0;
|
|
if (stagedChunkCount >= MAX_CHUNKS || args.index > stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
|
|
const existing = await ctx.db.query('assetChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('index', args.index)).first();
|
|
if (existing) {
|
|
// Convex may commit a mutation while its network response is lost. A
|
|
// duplicate delivery is accepted only when its payload is identical.
|
|
if (JSON.stringify(existing.assets) !== JSON.stringify(args.assets)) throw new Error('Asset chunk already exists');
|
|
return null;
|
|
}
|
|
if (args.index !== stagedChunkCount) throw new Error('Asset chunks must be staged exactly once and in order');
|
|
const localIds = new Set<string>();
|
|
for (const asset of args.assets) {
|
|
if (localIds.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
|
|
localIds.add(asset.itemId);
|
|
const existingFact = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', asset.itemId)).first();
|
|
if (existingFact) throw new Error('Snapshot contains duplicate asset IDs');
|
|
}
|
|
await ctx.db.insert('assetChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, assets: args.assets });
|
|
for (const asset of args.assets) await ctx.db.insert('assetFacts', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: asset.itemId, typeId: asset.typeId, quantity: asset.quantity, locationId: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag, ...(asset.isSingleton === undefined ? {} : { isSingleton: asset.isSingleton }), ...(asset.isBlueprintCopy === undefined ? {} : { isBlueprintCopy: asset.isBlueprintCopy }) });
|
|
await ctx.db.patch(generation._id, { stagedChunkCount: stagedChunkCount + 1, stagedAssetRowCount: (generation.stagedAssetRowCount ?? 0) + args.assets.length });
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeResetSyncEnrichment = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await jobCharacter(ctx, job);
|
|
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
|
|
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
|
|
const items = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).collect();
|
|
for (const chunk of chunks) await ctx.db.delete(chunk._id);
|
|
for (const item of items) await ctx.db.delete(item._id);
|
|
await ctx.db.patch(generation._id, { enrichmentStatus: undefined, enrichmentChunkCount: 0, enrichmentRowCount: 0, enrichmentByteCount: 0 });
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const bridgeStageSyncEnrichment = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), index: v.number(), rows: v.array(v.any()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
if (!Number.isInteger(args.index) || args.index < 0 || args.rows.length > MAX_LOCATION_CHUNK) throw new Error('Invalid location enrichment chunk');
|
|
const chunkBytes = boundedJsonBytes(args.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
|
|
const job = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await jobCharacter(ctx, job);
|
|
if (!job.generation || character.syncGeneration !== job.generation) throw new Error('Synchronization generation is not current');
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).first();
|
|
if (!generation || generation.status !== 'staging') throw new Error('Snapshot is not staging');
|
|
try { validatePersistedLocationRows(args.rows, { authority: { userId: job.userId, principal: character.ownerHash, characterId: job.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion } }); } catch { throw new Error('Invalid location enrichment rows'); }
|
|
const existing = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('index', args.index)).first();
|
|
if (existing) {
|
|
if (JSON.stringify(existing.rows) !== JSON.stringify(args.rows)) throw new Error('Location enrichment chunk already exists');
|
|
return null;
|
|
}
|
|
const localIds = new Set<string>();
|
|
for (const row of args.rows) {
|
|
if (localIds.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
|
|
localIds.add(row.itemId);
|
|
const asset = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
|
|
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
|
|
assertLocationRowMatchesAsset(row, asset, job.characterId);
|
|
const existingItem = await ctx.db.query('locationEnrichmentItems').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation).eq('itemId', row.itemId)).first();
|
|
if (existingItem) throw new Error('Location enrichment contains duplicate item IDs');
|
|
}
|
|
const stagedChunkCount = generation.enrichmentChunkCount ?? 0;
|
|
if (stagedChunkCount >= MAX_CHUNKS || args.index > stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
|
|
if (args.index !== stagedChunkCount) throw new Error('Location enrichment chunks must be staged exactly once and in order');
|
|
await ctx.db.insert('locationEnrichmentChunks', { userId: job.userId, characterId: job.characterId, generation: job.generation, index: args.index, rows: args.rows });
|
|
for (const row of args.rows) await ctx.db.insert('locationEnrichmentItems', { userId: job.userId, characterId: job.characterId, generation: job.generation, itemId: row.itemId });
|
|
const totalRows = (generation.enrichmentRowCount ?? 0) + args.rows.length;
|
|
if (totalRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
|
|
const totalBytes = (generation.enrichmentByteCount ?? 0) + chunkBytes;
|
|
if (totalBytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
|
|
await ctx.db.patch(generation._id, { enrichmentStatus: 'staging', enrichmentChunkCount: stagedChunkCount + 1, enrichmentRowCount: (generation.enrichmentRowCount ?? 0) + args.rows.length, enrichmentByteCount: totalBytes });
|
|
return null;
|
|
},
|
|
});
|
|
|
|
function boundedJsonBytes(value: unknown, limit: number, field: string): number {
|
|
let encoded: string;
|
|
try { encoded = JSON.stringify(value); } catch { throw new Error(`${field} is not serializable`); }
|
|
const bytes = new TextEncoder().encode(encoded).byteLength;
|
|
if (bytes > limit) throw new Error(`${field} exceeds byte bound`);
|
|
return bytes;
|
|
}
|
|
|
|
async function stagedAssetFacts(ctx: any, job: any, generation: any): Promise<Map<string, any>> {
|
|
if ((generation.stagedChunkCount ?? 0) > MAX_CHUNKS) throw new Error('Snapshot has too many asset chunks');
|
|
const expectedRows = generation.stagedAssetRowCount ?? 0;
|
|
if (!Number.isSafeInteger(expectedRows) || expectedRows < 0 || expectedRows > MAX_ASSET_ROWS) throw new Error('Snapshot exceeds asset row bound');
|
|
// Convex permits only one paginate() per function; the immutable manifest
|
|
// count is the bounded read limit, so never use an unbounded collect here.
|
|
if (expectedRows > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment source row bound exceeded');
|
|
const factsRows = await ctx.db.query('assetFacts').withIndex('by_snapshot_item', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).take(MAX_PERSISTED_ENRICHMENT_ROWS + 1);
|
|
if (factsRows.length !== expectedRows) throw new Error('Snapshot asset facts are incomplete');
|
|
const facts = new Map<string, any>();
|
|
for (const asset of factsRows) {
|
|
if (facts.has(asset.itemId)) throw new Error('Snapshot contains duplicate asset IDs');
|
|
facts.set(asset.itemId, asset);
|
|
}
|
|
return facts;
|
|
}
|
|
|
|
function sameOptionalFact(left: unknown, right: unknown): boolean { return left === right || (left === undefined && right === undefined); }
|
|
function assertLocationRowMatchesAsset(row: any, asset: any, characterId: number): void {
|
|
if (row.characterId !== characterId || row.itemId !== asset.itemId || row.typeId !== asset.typeId || row.quantity !== asset.quantity || row.locationId !== asset.locationId || row.locationType !== asset.locationType || row.locationFlag !== asset.locationFlag || !sameOptionalFact(row.isSingleton, asset.isSingleton) || !sameOptionalFact(row.isBlueprintCopy, asset.isBlueprintCopy)) throw new Error('Location enrichment row does not match staged asset facts');
|
|
}
|
|
|
|
function assertCanonicalContainment(rows: readonly any[], facts: Map<string, any>, characterId: number): void {
|
|
const assets = [...facts.values()].map((asset) => ({ ...asset, characterId }));
|
|
const canonical = resolveContainment({ assets: assets as any, characterId });
|
|
const byId = new Map(canonical.nodes.map((node) => [node.itemId, node]));
|
|
for (const row of rows) {
|
|
const expected = byId.get(row.itemId);
|
|
const sameRoot = expected && row.root.kind === expected.root.kind && row.root.id === expected.root.id && row.root.locationType === expected.root.locationType && row.root.locationFlag === expected.root.locationFlag;
|
|
if (!expected || !sameRoot || row.parentItemId !== expected.parentItemId || row.depth !== expected.depth || row.fitted.classification !== expected.fitted.classification || row.fitted.rawFlag !== expected.fitted.rawFlag || row.fitted.reason !== expected.fitted.reason || row.fittedClassification !== expected.fittedClassification || row.isFitted !== expected.isFitted || row.breadcrumbs.length !== expected.breadcrumbs.length || row.breadcrumbs.some((breadcrumb: any, index: number) => breadcrumb.itemId !== expected.breadcrumbs[index]?.itemId || breadcrumb.typeId !== expected.breadcrumbs[index]?.typeId)) throw new Error('Location enrichment containment projection is not canonical');
|
|
const rowReasons = new Set<string>(row.unresolved);
|
|
if (expected.unresolved.some((reason) => !rowReasons.has(reason))) throw new Error('Location enrichment containment reasons are incomplete');
|
|
const expectedLocationId = expected.root.id ?? expected.locationId;
|
|
if (row.location.id !== expectedLocationId) throw new Error('Location enrichment root location is inconsistent');
|
|
const expectedKinds: Record<string, readonly string[]> = { station: ['station', 'unknown'], solar_system: ['system', 'unknown'], other: ['structure', 'unknown'], item: ['unknown'] };
|
|
if (!(expectedKinds[expected.root.locationType] ?? ['unknown']).includes(row.location.kind)) throw new Error('Location enrichment root kind is inconsistent');
|
|
}
|
|
}
|
|
|
|
async function stagedLocationRows(ctx: any, job: any, generation: any, facts: Map<string, any>, authority: any): Promise<{ rows: any[]; ids: Set<string> }> {
|
|
const expectedChunks = generation.enrichmentChunkCount ?? 0;
|
|
if (!Number.isInteger(expectedChunks) || expectedChunks < 0 || expectedChunks > MAX_CHUNKS) throw new Error('Location enrichment chunks are incomplete');
|
|
const chunks = await ctx.db.query('locationEnrichmentChunks').withIndex('by_snapshot_index', (q: any) => q.eq('userId', job.userId).eq('characterId', job.characterId).eq('generation', job.generation)).order('asc').take(expectedChunks);
|
|
if (chunks.length !== expectedChunks) throw new Error('Location enrichment chunks are incomplete');
|
|
const rows: any[] = [];
|
|
const ids = new Set<string>();
|
|
let bytes = 0;
|
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
const chunk = chunks[index];
|
|
if (chunk.index !== index || !Array.isArray(chunk.rows) || chunk.rows.length > MAX_LOCATION_CHUNK) throw new Error('Location enrichment chunks are malformed');
|
|
if (rows.length + chunk.rows.length > MAX_PERSISTED_ENRICHMENT_ROWS) throw new Error('Location enrichment exceeds persistence row bound');
|
|
bytes += boundedJsonBytes(chunk.rows, MAX_LOCATION_CHUNK_BYTES, 'Location enrichment chunk');
|
|
if (bytes > MAX_ENRICHMENT_TOTAL_BYTES) throw new Error('Location enrichment exceeds persistence byte bound');
|
|
validatePersistedLocationRows(chunk.rows, { authority });
|
|
for (const row of chunk.rows) {
|
|
if (ids.has(row.itemId)) throw new Error('Location enrichment contains duplicate item IDs');
|
|
const asset = facts.get(row.itemId);
|
|
if (!asset) throw new Error('Location enrichment contains an unrequested item ID');
|
|
assertLocationRowMatchesAsset(row, asset, job.characterId);
|
|
ids.add(row.itemId); rows.push(row);
|
|
}
|
|
}
|
|
if ((generation.enrichmentByteCount ?? 0) !== bytes || (generation.enrichmentRowCount ?? 0) !== rows.length) throw new Error('Location enrichment row manifest is inconsistent');
|
|
return { rows, ids };
|
|
}
|
|
|
|
function validateLocationRowPinAgreement(rows: readonly any[], pin: any, characterId: number): number {
|
|
const unresolved = new Set<string>();
|
|
const namesRequested = new Set<string>(pin.names.requestedIds);
|
|
const namesResolved = new Set<string>(pin.names.resolvedIds);
|
|
const namesMissing = new Set<string>(pin.names.missingIds);
|
|
const namesBatchStates = new Map<string, string>((pin.names.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
|
|
const locationsRequested = new Set<string>(pin.locations.requestedIds);
|
|
const locationsResolved = new Set<string>(pin.locations.resolvedIds);
|
|
const locationsMissing = new Set<string>(pin.locations.missingIds);
|
|
const locationsBatchStates = new Map<string, string>((pin.locations.batches ?? []).flatMap((batch: any) => batch.requestedIds.map((id: string) => [id, batch.state] as [string, string])));
|
|
const structurePins = new Map<string, any>((pin.structures ?? []).map((observation: any) => [observation.structureId, observation]));
|
|
const referenceFacts = new Map<string, any>((pin.referenceFacts ?? []).map((fact: any) => [`${fact.kind}:${fact.id}`, fact]));
|
|
const visitReference = (reference: any): void => {
|
|
if (!reference || typeof reference !== 'object' || reference.referenceVersion !== pin.referenceVersion) throw new Error('Location row reference version does not match enrichment pin');
|
|
if (reference.kind === 'structure') {
|
|
const observation = structurePins.get(reference.id);
|
|
if (!observation || reference.name !== observation.name || reference.observedAt !== observation.observedAt || reference.expiresAt !== observation.expiresAt || (reference.system?.id !== undefined && reference.system.id !== observation.solarSystemId) || (reference.evidence === 'private_structure_access' && observation.status !== 'accessible') || (reference.evidence === 'unknown' && observation.status === 'accessible')) throw new Error('Structure row facts/freshness/source does not match enrichment pin');
|
|
} else if (reference.kind === 'station' || reference.kind === 'system' || reference.kind === 'region') {
|
|
const fact = referenceFacts.get(`${reference.kind}:${reference.id}`);
|
|
if (!fact || fact.name !== reference.name || fact.parentId !== reference.parentId || fact.evidence !== reference.evidence || fact.sourceVerified !== reference.sourceVerified || fact.systemId !== (reference.kind === 'station' ? reference.parentId : reference.system?.id ?? null) || fact.regionId !== (reference.region?.id ?? null)) throw new Error('Public location facts do not match enrichment pin');
|
|
}
|
|
if (reference.system !== null) visitReference(reference.system);
|
|
if (reference.region !== null) visitReference(reference.region);
|
|
};
|
|
for (const row of rows) {
|
|
if (row.characterId !== characterId) throw new Error('Location row character mismatch');
|
|
visitReference(row.location);
|
|
const nameRequested = namesRequested.has(row.itemId);
|
|
const locationRequested = locationsRequested.has(row.itemId);
|
|
const validateHelperStatus = (status: string, requested: boolean, resolved: boolean, missing: boolean, state: string, field: string): void => {
|
|
if (!requested) { if (status !== 'not_requested') throw new Error(`${field} row is not requested`); return; }
|
|
if (missing) {
|
|
if (status !== 'missing') throw new Error(`${field} row status disagrees with missing pin`);
|
|
} else if (resolved) {
|
|
if (status === 'not_requested' || status === 'forbidden' || status === 'unavailable') throw new Error(`${field} row status disagrees with resolved pin`);
|
|
} else if (state === 'forbidden' && status !== 'forbidden') throw new Error(`${field} row is not explicitly forbidden`);
|
|
else if (state === 'unavailable' && status !== 'unavailable') throw new Error(`${field} row is not explicitly unavailable`);
|
|
else if (state === 'observed') throw new Error(`${field} row is absent from observed pin`);
|
|
};
|
|
validateHelperStatus(row.nameStatus, nameRequested, namesResolved.has(row.itemId), namesMissing.has(row.itemId), namesBatchStates.get(row.itemId) ?? pin.names.state, 'name');
|
|
validateHelperStatus(row.locationStatus, locationRequested, locationsResolved.has(row.itemId), locationsMissing.has(row.itemId), locationsBatchStates.get(row.itemId) ?? pin.locations.state, 'location');
|
|
if (row.nameStatus === 'resolved' && !namesResolved.has(row.itemId)) throw new Error('Resolved name is absent from enrichment pin');
|
|
if (row.nameStatus === 'resolved' && typeof row.customName !== 'string') throw new Error('Resolved name has no custom name');
|
|
if (row.nameStatus !== 'resolved' && row.customName !== null) throw new Error('Unresolved name has a custom name');
|
|
if (row.nameStatus === 'not_requested' && nameRequested) throw new Error('Unrequested name row disagrees with enrichment pin');
|
|
if (row.nameStatus !== 'not_requested' && !nameRequested) throw new Error('Requested name row is absent from enrichment pin');
|
|
if (row.locationStatus === 'resolved' && !locationsResolved.has(row.itemId)) throw new Error('Resolved position is absent from enrichment pin');
|
|
if (row.locationStatus === 'resolved' && row.position === null) throw new Error('Resolved position is missing coordinates');
|
|
if (row.locationStatus !== 'resolved' && row.position !== null) throw new Error('Unresolved location has coordinates');
|
|
if (row.locationStatus === 'not_requested' && locationRequested) throw new Error('Unrequested location row disagrees with enrichment pin');
|
|
if (row.locationStatus !== 'not_requested' && !locationRequested) throw new Error('Requested location row is absent from enrichment pin');
|
|
const unresolvedReasons = new Set<string>(row.unresolved);
|
|
if (row.nameStatus === 'expired' && !unresolvedReasons.has('name_expired')) throw new Error('Expired name has no explanation');
|
|
if (row.nameStatus === 'forbidden' && !unresolvedReasons.has('name_forbidden')) throw new Error('Forbidden name has no explanation');
|
|
if (row.nameStatus === 'unavailable' && !unresolvedReasons.has('name_unavailable')) throw new Error('Unavailable name has no explanation');
|
|
if (row.locationStatus === 'expired' && !unresolvedReasons.has('location_expired')) throw new Error('Expired location has no explanation');
|
|
if (row.locationStatus === 'forbidden' && !unresolvedReasons.has('location_forbidden')) throw new Error('Forbidden location has no explanation');
|
|
if (row.locationStatus === 'unavailable' && !unresolvedReasons.has('location_unavailable')) throw new Error('Unavailable location has no explanation');
|
|
if (row.unresolved.length > 0) unresolved.add(row.itemId);
|
|
}
|
|
return unresolved.size;
|
|
}
|
|
|
|
function validJobSnapshotTimes(syncedAt: number, expiresAt: number, timestamp: number): boolean {
|
|
return Number.isFinite(syncedAt) && syncedAt >= 0 && syncedAt <= timestamp && Number.isFinite(expiresAt) && expiresAt > timestamp && expiresAt > syncedAt && expiresAt - syncedAt <= SNAPSHOT_MAX_LIFETIME_MS;
|
|
}
|
|
|
|
export const bridgeCommitSyncJob = mutationGeneric({
|
|
args: {
|
|
...workerBridgeArgs,
|
|
jobId: v.string(),
|
|
leaseToken: v.string(),
|
|
chunkCount: v.number(),
|
|
syncedAt: v.number(),
|
|
expiresAt: v.number(),
|
|
enrichmentStatus: v.optional(v.union(v.literal('complete'), v.literal('partial'), v.literal('failed'), v.literal('unavailable'))),
|
|
enrichmentVersion: v.optional(v.string()),
|
|
enrichmentInputPin: v.optional(v.any()),
|
|
enrichmentChunkCount: v.optional(v.number()),
|
|
enrichmentUnresolvedCount: v.optional(v.number()),
|
|
enrichmentError: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const job = await findJob(ctx, args.jobId);
|
|
if (job?.status === 'succeeded' && job.generation !== undefined) return true;
|
|
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await jobCharacter(ctx, leased);
|
|
if (!leased.generation) throw new Error('Synchronization generation is not current');
|
|
if (!Number.isInteger(args.chunkCount) || args.chunkCount < 0 || args.chunkCount > MAX_CHUNKS || !validJobSnapshotTimes(args.syncedAt, args.expiresAt, now())) throw new Error('Invalid snapshot');
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
|
|
if (!generation || generation.status !== 'staging' || (generation.stagedChunkCount ?? 0) !== args.chunkCount) throw new Error('Snapshot is incomplete');
|
|
if (generation.grantRevision !== character.grantRevision || generation.authorizationEpoch !== character.authorizationEpoch || generation.credentialVersion !== character.credentialVersion || character.authorizationStatus !== 'active') throw new Error('Synchronization authorization is stale');
|
|
const enrichmentStatus = args.enrichmentStatus;
|
|
const stagedEnrichmentChunks = generation.enrichmentChunkCount ?? 0;
|
|
if (enrichmentStatus === undefined) {
|
|
if (stagedEnrichmentChunks !== 0 || generation.enrichmentStatus !== undefined) throw new Error('Location enrichment manifest is missing');
|
|
} else {
|
|
const expectedEnrichmentChunks = args.enrichmentChunkCount ?? stagedEnrichmentChunks;
|
|
if (!Number.isInteger(expectedEnrichmentChunks) || expectedEnrichmentChunks < 0 || expectedEnrichmentChunks > MAX_CHUNKS || expectedEnrichmentChunks !== stagedEnrichmentChunks) throw new Error('Location enrichment is incomplete');
|
|
const authority = { userId: leased.userId, principal: character.ownerHash, characterId: leased.characterId, scopes: character.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion };
|
|
let pin: any;
|
|
if (enrichmentStatus !== 'failed' && enrichmentStatus !== 'unavailable') {
|
|
if (typeof args.enrichmentVersion !== 'string' || !args.enrichmentInputPin || typeof args.enrichmentInputPin !== 'object') throw new Error('Location enrichment version and pin are required');
|
|
boundedJsonBytes(args.enrichmentInputPin, MAX_ENRICHMENT_PIN_BYTES, 'Location enrichment pin');
|
|
try { validateReferenceEnrichmentPin(args.enrichmentInputPin, { assetGeneration: leased.generation, authority }); } catch { throw new Error('Location enrichment pin is invalid'); }
|
|
pin = args.enrichmentInputPin as any;
|
|
if (args.enrichmentVersion !== deterministicEnrichmentVersion(pin)) throw new Error('Location enrichment version is not deterministic');
|
|
}
|
|
if (enrichmentStatus === 'failed' || enrichmentStatus === 'unavailable') {
|
|
const stagedAssetRows = generation.stagedAssetRowCount ?? 0;
|
|
if (stagedEnrichmentChunks !== 0 || (generation.enrichmentRowCount ?? 0) !== 0 || (generation.enrichmentByteCount ?? 0) !== 0 || args.enrichmentInputPin !== undefined || args.enrichmentVersion !== undefined || args.enrichmentUnresolvedCount !== stagedAssetRows || typeof args.enrichmentError !== 'string' || args.enrichmentError.length === 0) throw new Error('Failed enrichment cannot publish rows or a complete pin');
|
|
} else {
|
|
const facts = await stagedAssetFacts(ctx, leased, generation);
|
|
const staged = await stagedLocationRows(ctx, leased, generation, facts, authority);
|
|
if (staged.rows.length !== facts.size || staged.ids.size !== facts.size || (generation.enrichmentRowCount ?? 0) !== facts.size) throw new Error('Location enrichment does not cover the staged asset generation');
|
|
assertCanonicalContainment(staged.rows, facts, leased.characterId);
|
|
if (enrichmentStatus === 'complete') {
|
|
const assetIds = new Set(facts.keys());
|
|
for (const part of [pin.names, pin.locations]) {
|
|
if (part.state === 'not_requested' || part.requestedIds.length !== assetIds.size || new Set(part.requestedIds).size !== assetIds.size || part.requestedIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin does not cover the staged asset generation');
|
|
if (new Set([...part.resolvedIds, ...part.missingIds]).size !== assetIds.size || part.resolvedIds.some((id: string) => !assetIds.has(id)) || part.missingIds.some((id: string) => !assetIds.has(id))) throw new Error('Complete enrichment helper pin coverage is incomplete');
|
|
}
|
|
}
|
|
const unresolvedCount = validateLocationRowPinAgreement(staged.rows, pin, leased.characterId);
|
|
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || args.enrichmentUnresolvedCount !== unresolvedCount) throw new Error('Location enrichment unresolved count is not truthful');
|
|
if (enrichmentStatus === 'complete' && unresolvedCount !== 0) throw new Error('Complete enrichment contains unresolved rows');
|
|
if (enrichmentStatus === 'partial' && unresolvedCount === 0 && pin.names.state === 'observed' && pin.locations.state === 'observed') throw new Error('Partial enrichment has no unresolved coverage');
|
|
}
|
|
if (!Number.isSafeInteger(args.enrichmentUnresolvedCount ?? 0) || (args.enrichmentUnresolvedCount ?? 0) < 0) throw new Error('Invalid enrichment unresolved count');
|
|
if (args.enrichmentError !== undefined && (typeof args.enrichmentError !== 'string' || args.enrichmentError.length > MAX_ERROR_LENGTH)) throw new Error('Invalid enrichment error');
|
|
}
|
|
await ctx.db.patch(generation._id, {
|
|
status: 'complete', chunkCount: args.chunkCount, syncedAt: args.syncedAt, expiresAt: args.expiresAt,
|
|
...(enrichmentStatus === undefined ? {} : {
|
|
enrichmentStatus,
|
|
...(args.enrichmentVersion === undefined ? {} : { enrichmentVersion: args.enrichmentVersion }),
|
|
...(args.enrichmentInputPin === undefined ? {} : { enrichmentInputPin: args.enrichmentInputPin }),
|
|
enrichmentChunkCount: args.enrichmentChunkCount ?? (generation.enrichmentChunkCount ?? 0),
|
|
enrichmentUnresolvedCount: args.enrichmentUnresolvedCount ?? 0,
|
|
...(args.enrichmentError === undefined ? {} : { enrichmentError: args.enrichmentError.slice(0, MAX_ERROR_LENGTH) }),
|
|
}),
|
|
});
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: 'ready', generation: leased.generation, syncedAt: args.syncedAt, error: undefined } : entry);
|
|
await ctx.db.patch(character._id, { state: 'ready', datasetStatuses: statuses, syncedAt: args.syncedAt, expiresAt: args.expiresAt, activeGeneration: leased.generation, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: undefined });
|
|
await ctx.db.patch(leased._id, { status: 'succeeded', updatedAt: now(), finishedAt: now(), leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, cancelRequested: undefined, lastError: undefined });
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const bridgeFailSyncJob = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const character = await jobCharacter(ctx, leased);
|
|
const timestamp = now();
|
|
if (leased.generation !== undefined) {
|
|
const generation = await ctx.db.query('assetGenerations').withIndex('by_user_character_generation', (q: any) => q.eq('userId', leased.userId).eq('characterId', leased.characterId).eq('generation', leased.generation)).first();
|
|
if (generation?.status === 'staging') await ctx.db.patch(generation._id, { status: 'failed', error: safeJobError() });
|
|
}
|
|
const statuses = datasetStatusesFor(character).map((entry: any) => entry.dataset === 'assets' ? { ...entry, status: character.activeGeneration ? 'error' : 'blocked', error: safeJobError() } : entry);
|
|
await ctx.db.patch(character._id, { state: 'error', datasetStatuses: statuses, error: safeJobError(), syncGeneration: undefined, syncLeaseExpiresAt: undefined });
|
|
await ctx.db.patch(leased._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
|
|
return true;
|
|
},
|
|
});
|
|
|
|
/** Reschedule transient provider/cooldown failures without exposing their raw text. */
|
|
export const bridgeDeferSyncJob = mutationGeneric({
|
|
args: { ...workerBridgeArgs, jobId: v.string(), leaseToken: v.string(), dueAt: v.number(), cooldownContext: v.optional(v.string()), cooldownUntil: v.optional(v.number()), cooldownGroup: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const leased = await jobLease(ctx, args.jobId, args.leaseToken);
|
|
const timestamp = now();
|
|
if (!Number.isFinite(args.dueAt) || args.dueAt < timestamp || args.dueAt > timestamp + 24 * 60 * 60 * 1000) throw new Error('Invalid retry due time');
|
|
if ((leased.attempts ?? 0) >= (leased.maxAttempts ?? 5)) {
|
|
await jobCharacter(ctx, leased);
|
|
await markTerminalSyncFailure(ctx, leased);
|
|
await ctx.db.patch(leased._id, { status: 'failed', updatedAt: timestamp, finishedAt: timestamp, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: safeJobError() });
|
|
return false;
|
|
}
|
|
await ctx.db.patch(leased._id, { status: 'queued', updatedAt: timestamp, dueAt: args.dueAt, leaseExpiresAt: undefined, leaseToken: undefined, workerId: undefined, lastError: 'Synchronization scheduled for retry', ...(args.cooldownGroup ? { cooldownGroup: args.cooldownGroup } : {}) });
|
|
if (args.cooldownContext && args.cooldownUntil !== undefined && Number.isFinite(args.cooldownUntil)) {
|
|
const contexts = [args.cooldownContext, ...(args.cooldownGroup ? [`group:${args.cooldownGroup}`] : [])];
|
|
for (const context of contexts) {
|
|
const existing = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first();
|
|
const until = Math.max(existing?.until ?? 0, args.cooldownUntil);
|
|
if (existing) await ctx.db.patch(existing._id, { until, group: args.cooldownGroup, kind: 'server', updatedAt: timestamp });
|
|
else await ctx.db.insert('syncCooldowns', { context, until, group: args.cooldownGroup, kind: 'server', updatedAt: timestamp });
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
async function currentCooldown(ctx: any, context: string, group?: string): Promise<any | null> {
|
|
const rows = [await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first()];
|
|
if (group) rows.push(await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', `group:${group}`)).first());
|
|
const row = rows.filter(Boolean).sort((left: any, right: any) => right.until - left.until)[0];
|
|
return row && row.until > now() ? { until: row.until, group: row.group, kind: row.kind } : null;
|
|
}
|
|
|
|
export const bridgeCooldown = queryGeneric({
|
|
args: { ...bridgeArgs, context: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
return await currentCooldown(ctx, args.context);
|
|
},
|
|
});
|
|
|
|
export const bridgeRecordSyncCooldown = mutationGeneric({
|
|
args: { ...workerBridgeArgs, context: v.string(), until: v.number(), group: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
if (!args.context || args.context.length > 256 || !Number.isFinite(args.until) || args.until < now() || args.until > now() + 24 * 60 * 60 * 1000 || (args.group !== undefined && (args.group.length === 0 || args.group.length > 128))) throw new Error('Invalid synchronization cooldown');
|
|
const timestamp = now();
|
|
const contexts = [args.context, ...(args.group ? [`group:${args.group}`] : [])];
|
|
for (const context of contexts) {
|
|
const existing = await ctx.db.query('syncCooldowns').withIndex('by_context', (q: any) => q.eq('context', context)).first();
|
|
const until = Math.max(existing?.until ?? 0, args.until);
|
|
if (existing) await ctx.db.patch(existing._id, { until, group: args.group, kind: 'server', updatedAt: timestamp });
|
|
else await ctx.db.insert('syncCooldowns', { context, until, group: args.group, kind: 'server', updatedAt: timestamp });
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const bridgeSyncWorkerCooldown = queryGeneric({
|
|
args: { ...workerBridgeArgs, context: v.string(), group: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
return await currentCooldown(ctx, args.context, args.group);
|
|
},
|
|
});
|
|
|
|
async function markRefreshUncertainRecord(ctx: any, userId: string, characterId: number, lease: any, character: any, record: any): Promise<boolean> {
|
|
if (!lease || !character || !record || record.version !== lease.expectedVersion) {
|
|
if (lease) await ctx.db.delete(lease._id);
|
|
return false;
|
|
}
|
|
const epoch = character.authorizationEpoch + 1;
|
|
const statuses = DATASET_NAMES.map((dataset) => ({ dataset, status: 'blocked' as const, grantRevision: character.grantRevision, authorizationEpoch: epoch, credentialVersion: character.credentialVersion, invalidatedAt: now(), error: 'Reconnect required' }));
|
|
// Retain the last complete generation metadata; authorization status blocks
|
|
// reads until reconnect, while a later reconnect can explicitly replace it.
|
|
await ctx.db.patch(character._id, { authorizationStatus: 'reconnect_required', authorizationEpoch: epoch, datasetStatuses: statuses, syncGeneration: undefined, syncLeaseExpiresAt: undefined, error: 'Reconnect required', state: 'error' });
|
|
await ctx.db.delete(record._id); await ctx.db.delete(lease._id);
|
|
await invalidateCharacterJobs(ctx, userId, characterId, now());
|
|
return true;
|
|
}
|
|
|
|
export const bridgeMarkRefreshDispatched = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt !== undefined) return false;
|
|
await ctx.db.patch(lease._id, { dispatchStartedAt: now() });
|
|
return true;
|
|
},
|
|
});
|
|
|
|
/** Durable per-character refresh serialization. The encrypted grant never reaches browsers. */
|
|
export const bridgeAcquireRefreshLease = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), ownerId: v.string(), leaseMs: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
if (!character || character.authorizationStatus !== 'active') throw new Error('Character authorization is not active');
|
|
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!record) throw new Error('Character authorization is missing');
|
|
const timestamp = now(); const leaseMs = args.leaseMs ?? 2 * 60 * 1000;
|
|
if (!Number.isSafeInteger(leaseMs) || leaseMs < 10_000 || leaseMs > 15 * 60 * 1000) throw new Error('Invalid refresh lease duration');
|
|
const existing = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (existing && existing.expiresAt > timestamp) return null;
|
|
if (existing?.dispatchStartedAt !== undefined) {
|
|
await markRefreshUncertainRecord(ctx, args.userId, args.characterId, existing, character, record);
|
|
return null;
|
|
}
|
|
if (existing) await ctx.db.delete(existing._id);
|
|
const leaseToken = newIdentifier('refresh');
|
|
await ctx.db.insert('refreshLeases', { userId: args.userId, characterId: args.characterId, leaseToken, ownerId: args.ownerId, expiresAt: timestamp + leaseMs, expectedVersion: record.version });
|
|
return { leaseToken, expiresAt: timestamp + leaseMs, expectedVersion: record.version, encryptedTokens: record.encryptedTokens, tokenExpiresAt: record.tokenExpiresAt, scopes: record.scopes, grantRevision: character.grantRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: character.credentialVersion };
|
|
},
|
|
});
|
|
|
|
export const bridgeRenewRefreshLease = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), leaseMs: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now()) return false;
|
|
const leaseMs = args.leaseMs ?? 2 * 60 * 1000;
|
|
if (!Number.isSafeInteger(leaseMs) || leaseMs < 10_000 || leaseMs > 15 * 60 * 1000) return false;
|
|
await ctx.db.patch(lease._id, { expiresAt: now() + leaseMs }); return true;
|
|
},
|
|
});
|
|
|
|
export const bridgeReleaseRefreshLease = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt !== undefined) return false;
|
|
await ctx.db.delete(lease._id);
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const bridgeCommitRefresh = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), expectedVersion: v.number(), encryptedTokens: v.string(), tokenExpiresAt: v.number(), scopes: v.array(v.string()), jobId: v.optional(v.string()), jobLeaseToken: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId || lease.expiresAt <= now() || lease.dispatchStartedAt === undefined || !character || character.authorizationStatus !== 'active' || !record || record.version !== args.expectedVersion) return null;
|
|
if (!validActualScopes(args.scopes) || !scopesSubset(args.scopes, character.scopes ?? [])) throw new Error('Invalid refreshed scopes');
|
|
const nextVersion = record.version + 1;
|
|
const nextRevision = character.grantRevision + (scopesEqual(character.scopes, args.scopes) ? 0 : 1);
|
|
const updated = updateDatasetStatuses(character, args.scopes, nextRevision, character.authorizationEpoch, nextVersion, now());
|
|
await ctx.db.patch(record._id, { encryptedTokens: args.encryptedTokens, tokenExpiresAt: args.tokenExpiresAt, scopes: args.scopes, version: nextVersion });
|
|
await ctx.db.patch(character._id, { scopes: args.scopes, grantRevision: nextRevision, credentialVersion: nextVersion, datasetStatuses: updated.statuses, ...(updated.assetsInvalidated ? { state: 'error' as const, activeGeneration: undefined, syncedAt: undefined, expiresAt: undefined, syncGeneration: undefined, syncLeaseExpiresAt: undefined } : {}) });
|
|
if (updated.assetsInvalidated) await invalidateCharacterJobs(ctx, args.userId, args.characterId, now());
|
|
else if (args.jobId && args.jobLeaseToken) {
|
|
const job = await findJob(ctx, args.jobId);
|
|
if (job && job.userId === args.userId && job.characterId === args.characterId && job.status === 'running' && job.leaseToken === args.jobLeaseToken) {
|
|
await ctx.db.patch(job._id, { grantRevision: nextRevision, authorizationEpoch: character.authorizationEpoch, credentialVersion: nextVersion, scopes: args.scopes, updatedAt: now() });
|
|
}
|
|
}
|
|
await ctx.db.delete(lease._id);
|
|
return { saved: true, credentialVersion: nextVersion, grantRevision: nextRevision, authorizationEpoch: character.authorizationEpoch, scopes: args.scopes };
|
|
},
|
|
});
|
|
|
|
/** Unknown rotating-token outcomes fail closed and require an explicit reconnect. */
|
|
export const bridgeMarkRefreshUncertain = mutationGeneric({
|
|
args: { ...workerBridgeArgs, userId: v.string(), characterId: v.number(), leaseToken: v.string(), ownerId: v.string(), expectedVersion: v.number() },
|
|
handler: async (ctx, args) => {
|
|
assertSyncWorker(args.workerSecret);
|
|
const lease = await ctx.db.query('refreshLeases').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (!lease || lease.leaseToken !== args.leaseToken || lease.ownerId !== args.ownerId) return false;
|
|
const character = await findCharacter(ctx, args.userId, args.characterId);
|
|
const record = await ctx.db.query('tokenRecords').withIndex('by_user_character', (q: any) => q.eq('userId', args.userId).eq('characterId', args.characterId)).first();
|
|
if (lease.expectedVersion !== args.expectedVersion) return false;
|
|
return await markRefreshUncertainRecord(ctx, args.userId, args.characterId, lease, character, record);
|
|
},
|
|
});
|
|
|
|
export const bridgeSaveInteraction = mutationGeneric({
|
|
args: {
|
|
...bridgeArgs,
|
|
userId: v.string(),
|
|
requestId: v.string(),
|
|
message: v.string(),
|
|
result: v.any(),
|
|
status: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
assertBridge(args.bridgeSecret);
|
|
const existing = await ctx.db
|
|
.query("interactions")
|
|
.withIndex("by_user_request", (q: any) =>
|
|
q.eq("userId", args.userId).eq("requestId", args.requestId),
|
|
)
|
|
.first();
|
|
if (existing) {
|
|
if (existing.message !== args.message) {
|
|
throw new Error("Interaction request ID was reused with a different message");
|
|
}
|
|
// A request can first be durably recorded as blocked by auth or missing
|
|
// sync requirements, then resumed after the user completes that action.
|
|
// Keep the idempotency key and original message, but advance its result.
|
|
if (existing.status !== args.status || existing.result !== args.result) {
|
|
await ctx.db.patch(existing._id, {
|
|
result: args.result,
|
|
status: args.status,
|
|
});
|
|
}
|
|
return {
|
|
requestId: existing.requestId,
|
|
message: existing.message,
|
|
result: args.result,
|
|
status: args.status,
|
|
createdAt: existing.createdAt,
|
|
created: false,
|
|
};
|
|
}
|
|
const createdAt = now();
|
|
await ctx.db.insert("interactions", {
|
|
userId: args.userId,
|
|
requestId: args.requestId,
|
|
message: args.message,
|
|
result: args.result,
|
|
status: args.status,
|
|
createdAt,
|
|
});
|
|
return {
|
|
requestId: args.requestId,
|
|
message: args.message,
|
|
result: args.result,
|
|
status: args.status,
|
|
createdAt,
|
|
created: true,
|
|
};
|
|
},
|
|
});
|
|
|