Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 74 additions & 65 deletions apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,83 +70,92 @@ export async function fetchAndApplySessions(params: {
cursor = nextCursor;
}

// Initialize all session encryptions first
// Decrypt all session keys in parallel
const sessionKeys = new Map<string, Uint8Array | null>();
for (const session of sessions) {
if (session.dataEncryptionKey) {
const decrypted = await encryption.decryptEncryptionKey(session.dataEncryptionKey);
if (!decrypted) {
console.error(`Failed to decrypt data encryption key for session ${session.id}`);
sessionKeys.set(session.id, null);
sessionDataKeys.delete(session.id);
continue;
const keyResults = await Promise.all(
sessions.map(async (session) => {
if (session.dataEncryptionKey) {
const decrypted = await encryption.decryptEncryptionKey(session.dataEncryptionKey);
return { id: session.id, decrypted, hasKey: true };
}
sessionKeys.set(session.id, decrypted);
sessionDataKeys.set(session.id, decrypted);
return { id: session.id, decrypted: null, hasKey: false };
}),
);
for (const { id, decrypted, hasKey } of keyResults) {
if (hasKey && !decrypted) {
console.error(`Failed to decrypt data encryption key for session ${id}`);
sessionKeys.set(id, null);
sessionDataKeys.delete(id);
} else if (hasKey && decrypted) {
sessionKeys.set(id, decrypted);
sessionDataKeys.set(id, decrypted);
} else {
sessionKeys.set(session.id, null);
sessionDataKeys.delete(session.id);
sessionKeys.set(id, null);
sessionDataKeys.delete(id);
}
}
await encryption.initializeSessions(sessionKeys);

// Decrypt sessions
const decryptedSessions: (Omit<Session, 'presence'> & { presence?: 'online' | number })[] = [];
for (const session of sessions) {
const encryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee';

const sessionEncryption = encryption.getSessionEncryption(session.id);
if (encryptionMode === 'e2ee' && !sessionEncryption) {
console.error(`Session encryption not found for ${session.id} - this should never happen`);
continue;
// Decrypt all sessions in parallel
const parsePlainMetadata = (value: string): Metadata | null => {
try {
const parsedJson = JSON.parse(value);
const parsed = MetadataSchema.safeParse(parsedJson);
return parsed.success ? parsed.data : null;
} catch {
return null;
}
};

const parsePlainAgentState = (value: string | null): unknown => {
if (!value) return {};
try {
const parsedJson = JSON.parse(value);
const parsed = AgentStateSchema.safeParse(parsedJson);
return parsed.success ? parsed.data : {};
} catch {
return {};
}
};

const parsePlainMetadata = (value: string): Metadata | null => {
try {
const parsedJson = JSON.parse(value);
const parsed = MetadataSchema.safeParse(parsedJson);
return parsed.success ? parsed.data : null;
} catch {
const decryptedSessionResults = await Promise.all(
sessions.map(async (session) => {
const encryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee';

const sessionEncryption = encryption.getSessionEncryption(session.id);
if (encryptionMode === 'e2ee' && !sessionEncryption) {
console.error(`Session encryption not found for ${session.id} - this should never happen`);
return null;
}
};

const parsePlainAgentState = (value: string | null): unknown => {
if (!value) return {};
try {
const parsedJson = JSON.parse(value);
const parsed = AgentStateSchema.safeParse(parsedJson);
return parsed.success ? parsed.data : {};
} catch {
return {};
}
};

const metadata =
encryptionMode === 'plain'
? parsePlainMetadata(session.metadata)
: await sessionEncryption!.decryptMetadata(session.metadataVersion, session.metadata);

const agentState =
encryptionMode === 'plain'
? parsePlainAgentState(session.agentState)
: await sessionEncryption!.decryptAgentState(session.agentStateVersion, session.agentState);

// Put it all together
const accessLevel = session.share?.accessLevel;
const normalizedAccessLevel =
accessLevel === 'view' || accessLevel === 'edit' || accessLevel === 'admin' ? accessLevel : undefined;
decryptedSessions.push({
...session,
encryptionMode,
thinking: false,
thinkingAt: 0,
metadata,
agentState,
accessLevel: normalizedAccessLevel,
canApprovePermissions: session.share?.canApprovePermissions ?? undefined,
});
}
const metadata =
encryptionMode === 'plain'
? parsePlainMetadata(session.metadata)
: await sessionEncryption!.decryptMetadata(session.metadataVersion, session.metadata);

const agentState =
encryptionMode === 'plain'
? parsePlainAgentState(session.agentState)
: await sessionEncryption!.decryptAgentState(session.agentStateVersion, session.agentState);

const accessLevel = session.share?.accessLevel;
const normalizedAccessLevel =
accessLevel === 'view' || accessLevel === 'edit' || accessLevel === 'admin' ? accessLevel : undefined;
return {
...session,
encryptionMode,
thinking: false,
thinkingAt: 0,
metadata,
agentState,
accessLevel: normalizedAccessLevel,
canApprovePermissions: session.share?.canApprovePermissions ?? undefined,
};
}),
);
const decryptedSessions = decryptedSessionResults.filter(
(s): s is NonNullable<typeof s> => s !== null,
);
Comment on lines +126 to +168
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all fail-fast drops all sessions on a single decryption error

Promise.all rejects immediately if any of the async callbacks throws. This means that if sessionEncryption!.decryptMetadata(...) or sessionEncryption!.decryptAgentState(...) rejects for even one session, the entire decryptedSessionResults promise rejects — no sessions will be applied, and applySessions is never called.

In the original sequential for-await loop the behaviour was the same (a throw would propagate), but migrating to parallel execution is the natural moment to also make error handling more resilient. Switching to Promise.allSettled and filtering out rejected outcomes would let successfully-decrypted sessions still load even when one session's crypto call fails:

const decryptedSessionResults = await Promise.allSettled(
    sessions.map(async (session) => {
        // ... same body ...
    }),
);
const decryptedSessions = decryptedSessionResults
    .filter((r): r is PromiseFulfilledResult<NonNullable<...>> =>
        r.status === 'fulfilled' && r.value !== null,
    )
    .map((r) => r.value);

The same concern applies to the first Promise.all at lines 75–83: if encryption.decryptEncryptionKey rejects for any session, the for loop on line 84 never runs, initializeSessions is never called, and the function throws — potentially leaving sessionDataKeys in whatever state it was before this call.


// Apply to storage
applySessions(decryptedSessions);
Expand Down