Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions packages/fluux-sdk/src/core/defaultStoreBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { defaultStores, type SDKStores } from '../stores'
import type { StoreBindings } from './types/client'
import type { Message } from './types/chat'
import {
connectionBindingMethodKeys,
chatBindingMethodKeys,
Expand Down Expand Up @@ -114,6 +115,15 @@ export function createDefaultStoreBindings(stores: SDKStores = defaultStores): S
Array.from(chatStore.getState().messages, ([id, messages]) => ({ id, messages })),
getConversationMessages: (conversationId: string) =>
chatStore.getState().messages.get(conversationId) ?? [],
getEncryptedPreviews: () => {
const result: Array<{ conversationId: string; lastMessage: Message }> = []
for (const [conversationId, meta] of chatStore.getState().conversationMeta) {
if (meta.lastMessage?.encryptedPayload) {
result.push({ conversationId, lastMessage: meta.lastMessage })
}
}
return result
},
},
roster: bindStoreMethods(rosterStore, rosterBindingMethodKeys),
console: bindStoreMethods(consoleStore, consoleBindingMethodKeys),
Expand Down
75 changes: 75 additions & 0 deletions packages/fluux-sdk/src/core/e2ee/deferredDecrypt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,81 @@ describe('DeferredDecryptEngine', () => {
expect(updates).toMatchObject({ body: 'hello', encryptedPayload: undefined })
})

it('heals an orphaned encrypted sidebar preview from its own stashed payload', async () => {
// The stuck-preview class: the conversation is NOT loaded (empty
// getAllStoredMessages) and its message is not pending in the durable cache
// (already decrypted there, or evicted) — so neither the in-memory nor the
// durable pass reaches it. The only carrier of the ciphertext is the
// persisted preview itself, which still holds `encryptedPayload`. Without a
// preview-level heal the sidebar stays on "[OpenPGP-encrypted message]"
// until the conversation is opened.
vi.spyOn(manager, 'decryptArchive').mockResolvedValue({
plaintext: new TextEncoder().encode('hello'),
senderDevice: { jid: 'bob@example.com', deviceId: 'test' },
securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' },
})

const preview: Message = {
type: 'chat',
id: 'msg-preview',
conversationId: 'bob@example.com',
from: 'bob@example.com',
body: '[OpenPGP-encrypted message]',
timestamp: new Date(),
isOutgoing: false,
encryptedPayload: DUMMY_PAYLOAD_XML,
}
// No loaded messages, nothing pending in the durable cache — the preview is
// the sole carrier of the ciphertext.
stores.chat.getAllStoredMessages.mockReturnValue([])
stores.chat.getEncryptedPreviews.mockReturnValue([
{ conversationId: 'bob@example.com', lastMessage: preview },
])

const count = await engine.retryPending()

expect(count).toBe(1)
expect(stores.chat.refreshLastMessageContent).toHaveBeenCalledTimes(1)
const [conversationId, messageId, updates] =
stores.chat.refreshLastMessageContent.mock.calls[0]
expect(conversationId).toBe('bob@example.com')
expect(messageId).toBe('msg-preview')
expect(updates).toMatchObject({ body: 'hello', encryptedPayload: undefined })
})

it('does not re-decrypt a preview already handled by the message-store pass', async () => {
// When the conversation IS loaded, the in-memory pass decrypts the message
// and heals its preview via updateMessage. The preview-level pass must not
// double-process it: once the store pass clears `encryptedPayload`,
// getEncryptedPreviews no longer returns it.
vi.spyOn(manager, 'decryptArchive').mockResolvedValue({
plaintext: new TextEncoder().encode('hello'),
senderDevice: { jid: 'bob@example.com', deviceId: 'test' },
securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' },
})

const pending: Message = {
type: 'chat',
id: 'msg-loaded',
conversationId: 'bob@example.com',
from: 'bob@example.com',
body: '[OpenPGP-encrypted message]',
timestamp: new Date(),
isOutgoing: false,
encryptedPayload: DUMMY_PAYLOAD_XML,
}
stores.chat.getAllStoredMessages.mockReturnValue([
{ id: 'bob@example.com', messages: [pending] },
])
// The store pass cleared the stash, so the preview enumeration returns nothing.
stores.chat.getEncryptedPreviews.mockReturnValue([])

await engine.retryPending()

expect(stores.chat.updateMessage).toHaveBeenCalledTimes(1)
expect(stores.chat.refreshLastMessageContent).not.toHaveBeenCalled()
})

it('is a no-op when no E2EE manager is available', async () => {
engine = new DeferredDecryptEngine({
getManager: () => null,
Expand Down
49 changes: 49 additions & 0 deletions packages/fluux-sdk/src/core/e2ee/deferredDecrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ export class DeferredDecryptEngine {
const conversationId = msg.conversationId
if (!msg.encryptedPayload || !conversationId) continue
if (handledChatKeys.has(`${conversationId} ${msg.id}`)) continue
// Record so the preview-heal pass below skips a message this pass
// already repaired (it heals the preview via refreshLastMessageContent).
handledChatKeys.add(`${conversationId} ${msg.id}`)
const outcome = await this.decryptSingle(
manager, msg.encryptedPayload, msg.from, conversationId,
)
Expand Down Expand Up @@ -258,6 +261,52 @@ export class DeferredDecryptEngine {
}
}

// --- Orphaned sidebar previews ---
// A conversation's persisted preview (`conversationMeta.lastMessage`) can
// carry an `encryptedPayload` + "[OpenPGP-encrypted message]" fallback that
// NO message-store pass above reaches: the underlying message may have been
// evicted from IndexedDB, already decrypted there (so the durable scan skips
// it), or set preview-only by a MAM preview refresh that never stored a
// message row. The preview itself is then the sole carrier of the
// ciphertext, and would stay stuck on the fallback until the conversation is
// opened. Re-decrypt straight from the stash and heal the preview in place.
// Runs after the store passes so a preview already healed by them (its
// `encryptedPayload` cleared) is naturally excluded from the enumeration.
for (const { conversationId, lastMessage } of chatBindings.getEncryptedPreviews?.() ?? []) {
if (!lastMessage.encryptedPayload) continue
// Skip a preview whose message a store pass already repaired — that pass
// heals the preview in place (updateMessage / refreshLastMessageContent),
// so re-decrypting here would be redundant work.
if (handledChatKeys.has(`${conversationId} ${lastMessage.id}`)) continue
const outcome = await this.decryptSingle(
manager, lastMessage.encryptedPayload, lastMessage.from, conversationId,
)
if (outcome.kind === 'decrypted') {
chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, {
body: outcome.body,
...(outcome.securityContext && { securityContext: outcome.securityContext }),
...(outcome.attachment && { attachment: outcome.attachment }),
encryptedPayload: undefined,
})
decryptedCount++
} else if (outcome.kind === 'unsupported') {
chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, {
encryptedPayload: undefined,
unsupportedEncryption: outcome.info,
})
} else if (outcome.kind === 'rejected') {
// A preview is always a real previewable message (bodiless-signal
// placeholders are never previewable), so warn with the rejected body.
chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, {
body: MESSAGE_REJECTED_BODY,
...(outcome.securityContext && { securityContext: outcome.securityContext }),
encryptedPayload: undefined,
})
}
// 'modification' (reaction/retraction) can't be a preview, and 'pending'
// means the key is still locked — leave the stash for a later pass.
}

if (decryptedCount > 0) {
logInfo(`E2EE deferred decrypt: successfully decrypted ${decryptedCount} message(s)`)
}
Expand Down
6 changes: 5 additions & 1 deletion packages/fluux-sdk/src/core/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {

// Type utility to convert all function properties to Vitest Mock functions
type MockifyFunctions<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
// NonNullable unwraps OPTIONAL function members (e.g. `getEncryptedPreviews?`)
// so they mockify to a non-optional Mock — createMockStores provides every
// one, matching runtime — instead of falling through as `fn | undefined`.
[K in keyof T]-?: NonNullable<T[K]> extends (...args: infer A) => infer R
? Mock<(...args: A) => R>
: T[K]
}
Expand Down Expand Up @@ -649,6 +652,7 @@ export const createMockStores = (): MockStoreBindings => ({
getLastMessage: vi.fn().mockReturnValue(undefined),
getAllStoredMessages: vi.fn().mockReturnValue([]),
getConversationMessages: vi.fn().mockReturnValue([]),
getEncryptedPreviews: vi.fn().mockReturnValue([]),
},
roster: {
...mockMethods(rosterBindingMethodKeys),
Expand Down
6 changes: 6 additions & 0 deletions packages/fluux-sdk/src/core/types/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export interface StoreBindings {
// In-memory messages for a single conversation (archived included). Read
// seam for peer-scoped deferred-decrypt retry on a PEP key change.
getConversationMessages: (conversationId: string) => Message[]
// Every conversation whose sidebar preview still carries an
// `encryptedPayload` (archived INCLUDED). Read seam for the deferred-decrypt
// engine's preview-level heal: a preview can hold ciphertext that no message
// store reaches (its message evicted, already decrypted in IndexedDB, or set
// preview-only) — so it must be re-decrypted straight from its own stash.
getEncryptedPreviews?: () => Array<{ conversationId: string; lastMessage: Message }>
}
roster: Pick<RosterState, (typeof rosterBindingMethodKeys)[number]>
console: Pick<ConsoleState, (typeof consoleBindingMethodKeys)[number]>
Expand Down
Loading