From 47218d2d8c16875d6b2a970b8afad193a2068c65 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 17:35:44 +0100 Subject: [PATCH 01/19] refactor: extract CryptoFacade, remove direct CryptoManager coupling Co-Authored-By: Claude Opus 4.6 (1M context) --- index.mjs | 61 ++++++++----- src/command-api.mjs | 76 +++------------ src/crypto-facade.mjs | 208 ++++++++++++++++++++++++++++++++++++++++++ src/http-api.mjs | 16 ---- src/project.mjs | 152 +++++++++++------------------- src/timeline-api.mjs | 75 ++++++--------- 6 files changed, 342 insertions(+), 246 deletions(-) create mode 100644 src/crypto-facade.mjs diff --git a/index.mjs b/index.mjs index 0f175db..61c8125 100644 --- a/index.mjs +++ b/index.mjs @@ -8,6 +8,7 @@ import { discover, errors } from './src/discover-api.mjs' import { setLogger, LEVELS, consoleLogger, noopLogger } from './src/logger.mjs' import { chill } from './src/convenience.mjs' import { CryptoManager, TrustRequirement, VerificationMethod, VerificationRequestPhase } from './src/crypto.mjs' +import { CryptoFacade } from './src/crypto-facade.mjs' /* connect() resolves if the home_server can be connected. It does @@ -43,44 +44,44 @@ const connect = (home_server_url) => async (controller) => { * @property {string} [encryption.storeName] - IndexedDB store name for persistent crypto state (e.g. 'crypto-') * @property {string} [encryption.passphrase] - Passphrase to encrypt the IndexedDB store * @property {Object} db - A levelup-compatible database instance for the persistent command queue - * - * @param {LoginData} loginData + * + * @param {LoginData} loginData * @returns {Object} matrixClient */ const MatrixClient = (loginData) => { const encryption = loginData.encryption || null - // Shared CryptoManager instance – initialized once, reused across projectList/project calls - let sharedCryptoManager = null + // Shared CryptoFacade instance – initialized once, reused across projectList/project calls + let sharedFacade = null let cryptoInitialized = false /** - * Get or create the shared CryptoManager. + * Get or create the shared CryptoFacade. * If encryption.storeName is provided, uses IndexedDB-backed persistent store. * Otherwise, uses in-memory store (keys lost on restart). * @param {HttpAPI} httpAPI - * @returns {Promise<{cryptoManager: CryptoManager, httpAPI: HttpAPI} | null>} + * @returns {Promise} */ const getCrypto = async (httpAPI) => { if (!encryption?.enabled) return null - if (sharedCryptoManager) { - // Reuse existing CryptoManager, just process any pending outgoing requests + if (sharedFacade) { + // Reuse existing facade, just process any pending outgoing requests if (!cryptoInitialized) { - await httpAPI.processOutgoingCryptoRequests(sharedCryptoManager) + await sharedFacade.processOutgoingRequests() cryptoInitialized = true } - return { cryptoManager: sharedCryptoManager, httpAPI } + return sharedFacade } const credentials = httpAPI.credentials if (!credentials.device_id) { throw new Error('E2EE requires a device_id in credentials. Ensure a fresh login (delete .state.json if reusing saved credentials).') } - sharedCryptoManager = new CryptoManager() + const cryptoManager = new CryptoManager() if (encryption.storeName) { // Persistent store: crypto state survives restarts (requires IndexedDB, i.e. Electron/browser) - await sharedCryptoManager.initializeWithStore( + await cryptoManager.initializeWithStore( credentials.user_id, credentials.device_id, encryption.storeName, @@ -88,24 +89,28 @@ const MatrixClient = (loginData) => { ) } else { // In-memory: keys are lost on restart (for testing or non-browser environments) - await sharedCryptoManager.initialize(credentials.user_id, credentials.device_id) + await cryptoManager.initialize(credentials.user_id, credentials.device_id) } - await httpAPI.processOutgoingCryptoRequests(sharedCryptoManager) + sharedFacade = new CryptoFacade(cryptoManager, httpAPI) + await sharedFacade.processOutgoingRequests() cryptoInitialized = true - return { cryptoManager: sharedCryptoManager, httpAPI } + return sharedFacade } return { connect: connect(loginData.home_server_url), - + projectList: async mostRecentCredentials => { const credentials = mostRecentCredentials ? mostRecentCredentials : (await HttpAPI.loginWithPassword(loginData)) const httpAPI = new HttpAPI(credentials) - const crypto = await getCrypto(httpAPI) + const facade = await getCrypto(httpAPI) const projectListParames = { structureAPI: new StructureAPI(httpAPI), - timelineAPI: new TimelineAPI(httpAPI, crypto) + timelineAPI: new TimelineAPI(httpAPI, facade ? { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) + } : {}) } const projectList = new ProjectList(projectListParames) projectList.tokenRefreshed = handler => httpAPI.tokenRefreshed(handler) @@ -116,12 +121,24 @@ const MatrixClient = (loginData) => { project: async mostRecentCredentials => { const credentials = mostRecentCredentials ? mostRecentCredentials : (await HttpAPI.loginWithPassword(loginData)) const httpAPI = new HttpAPI(credentials) - const crypto = await getCrypto(httpAPI) + const facade = await getCrypto(httpAPI) const projectParams = { structureAPI: new StructureAPI(httpAPI), - timelineAPI: new TimelineAPI(httpAPI, crypto), - commandAPI: new CommandAPI(httpAPI, crypto?.cryptoManager || null, loginData.db), - cryptoManager: crypto?.cryptoManager || null + timelineAPI: new TimelineAPI(httpAPI, facade ? { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) + } : {}), + commandAPI: new CommandAPI(httpAPI, { + encryptEvent: facade + ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) + : null, + db: loginData.db + }), + crypto: facade ? { + isEnabled: true, + registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), + shareHistoricalKeys: (roomId, userIds) => facade.shareHistoricalKeys(roomId, userIds) + } : { isEnabled: false } } const project = new Project(projectParams) project.tokenRefreshed = handler => httpAPI.tokenRefreshed(handler) diff --git a/src/command-api.mjs b/src/command-api.mjs index c0f000a..82838ab 100644 --- a/src/command-api.mjs +++ b/src/command-api.mjs @@ -4,20 +4,21 @@ import { getLogger } from './logger.mjs' class CommandAPI { /** * @param {import('./http-api.mjs').HttpAPI} httpAPI - * @param {import('./crypto.mjs').CryptoManager} [cryptoManager] - Optional CryptoManager for E2EE - * @param {Object} db - A levelup-compatible database instance for persistent queue storage + * @param {Object} [options={}] + * @param {Function} [options.encryptEvent] - async (roomId, eventType, content, memberIds) => encryptedContent + * @param {Object} [options.db] - A levelup-compatible database instance for persistent queue storage */ - constructor (httpAPI, cryptoManager, db) { + constructor (httpAPI, options = {}) { this.httpAPI = httpAPI - this.cryptoManager = cryptoManager || null - this.scheduledCalls = new FIFO(db) + this.encryptEvent = options.encryptEvent || null + this.scheduledCalls = new FIFO(options.db) } /** * @param {FunctionCall} functionCall - * @description A functionCall is an array of parameters. The first is the name of the function that will be called. + * @description A functionCall is an array of parameters. The first is the name of the function that will be called. * All other params (0..n) must meet the signature of that function. - * + * * There is no way to retrieve the returning result of that function. */ schedule (functionCall) { @@ -36,7 +37,7 @@ class CommandAPI { async run () { /** - * @param {Number} retryCounter + * @param {Number} retryCounter * @returns A promise that resolves after a calculated time depending on the retryCounter using an exponential back-off algorithm. The max. delay is 30s. */ const chill = retryCounter => new Promise(resolve => { @@ -50,7 +51,7 @@ class CommandAPI { if (this.controller) return this.controller = new AbortController() - + let retryCounter = 0 let entry @@ -70,65 +71,16 @@ class CommandAPI { } // Encrypt outgoing message events if crypto is available - if (this.cryptoManager && functionName === 'sendMessageEvent') { + if (this.encryptEvent && functionName === 'sendMessageEvent') { const [roomId, eventType, content, ...rest] = params const log = getLogger() try { - // 1. Get room members const members = await this.httpAPI.members(roomId) const memberIds = (members.chunk || []) .filter(e => e.content?.membership === 'join') .map(e => e.state_key) .filter(Boolean) - log.debug('E2EE: room members:', memberIds) - - // 2. Track users and explicitly query their device keys - await this.cryptoManager.updateTrackedUsers(memberIds) - const keysQueryRequest = await this.cryptoManager.queryKeysForUsers(memberIds) - if (keysQueryRequest) { - log.debug('E2EE: querying device keys for', memberIds.length, 'users') - const queryResponse = await this.httpAPI.sendOutgoingCryptoRequest(keysQueryRequest) - await this.cryptoManager.markRequestAsSent(keysQueryRequest.id, keysQueryRequest.type, queryResponse) - } - - // 3. Process any other pending outgoing requests - await this.httpAPI.processOutgoingCryptoRequests(this.cryptoManager) - - // 4. Claim missing Olm sessions - const claimRequest = await this.cryptoManager.getMissingSessions(memberIds) - if (claimRequest) { - log.debug('E2EE: claiming missing Olm sessions') - const claimResponse = await this.httpAPI.sendOutgoingCryptoRequest(claimRequest) - await this.cryptoManager.markRequestAsSent(claimRequest.id, claimRequest.type, claimResponse) - } - - // 5. Share Megolm session key with all room members' devices - const shareRequests = await this.cryptoManager.shareRoomKey(roomId, memberIds) - log.debug('E2EE: shareRoomKey returned', shareRequests.length, 'to_device requests') - for (const req of shareRequests) { - // Log which devices receive keys vs withheld - try { - const body = JSON.parse(req.body) - const eventType = req.event_type || req.eventType || 'unknown' - log.debug(`E2EE: to_device type=${eventType}`) - if (body.messages) { - for (const [userId, devices] of Object.entries(body.messages)) { - for (const [deviceId, content] of Object.entries(devices)) { - log.debug(`E2EE: → ${userId} / ${deviceId}`) - } - } - } - } catch { /* ignore parse errors */ } - const resp = await this.httpAPI.sendOutgoingCryptoRequest(req) - await this.cryptoManager.markRequestAsSent(req.id, req.type, resp) - } - - // 6. Process any remaining outgoing requests - await this.httpAPI.processOutgoingCryptoRequests(this.cryptoManager) - - // 7. Encrypt the actual message - const encrypted = await this.cryptoManager.encryptRoomEvent(roomId, eventType, content) - log.debug('E2EE: message encrypted for room', roomId) + const encrypted = await this.encryptEvent(roomId, eventType, content, memberIds) params = [roomId, 'm.room.encrypted', encrypted, ...rest] } catch (encryptError) { log.warn('Encryption failed, sending unencrypted:', encryptError.message) @@ -146,7 +98,7 @@ class CommandAPI { if (error.response?.statusCode === 403) { log.error('Command forbidden:', entry.command[0], error.response.body) } - + /* In most cases we will have to deal with socket errors. The users computer may be offline or the server might be unreachable. @@ -154,7 +106,7 @@ class CommandAPI { this.scheduledCalls.requeue(entry.command, entry.key) retryCounter++ } - } + } } async stop () { diff --git a/src/crypto-facade.mjs b/src/crypto-facade.mjs new file mode 100644 index 0000000..0082e87 --- /dev/null +++ b/src/crypto-facade.mjs @@ -0,0 +1,208 @@ +import { getLogger } from './logger.mjs' + +/** + * High-level facade that encapsulates all crypto orchestration. + * + * Owns the CryptoManager and HttpAPI references. No other module should + * import or interact with CryptoManager directly — they receive narrow + * callback functions wired through this facade at composition time. + * + * Responsibilities: + * - Feed sync response data into the OlmMachine + * - Decrypt room events + * - Register rooms as encrypted + * - Full encrypt ceremony (track → queryKeys → claimSessions → shareRoomKey → encrypt → processOutgoing) + * - Share historical Megolm session keys with specific users + * - Process pending outgoing crypto requests + */ +class CryptoFacade { + /** + * @param {import('./crypto.mjs').CryptoManager} cryptoManager + * @param {import('./http-api.mjs').HttpAPI} httpAPI + */ + constructor (cryptoManager, httpAPI) { + this.cryptoManager = cryptoManager + this.httpAPI = httpAPI + } + + /** + * Process all pending outgoing crypto requests from the OlmMachine. + * Each request is sent via the appropriate HTTP endpoint, then marked as sent. + */ + async processOutgoingRequests () { + const log = getLogger() + const requests = await this.cryptoManager.outgoingRequests() + for (const request of requests) { + try { + const response = await this.httpAPI.sendOutgoingCryptoRequest(request) + await this.cryptoManager.markRequestAsSent(request.id, request.type, response) + } catch (error) { + log.error('Failed to process outgoing crypto request:', error.message) + } + } + } + + /** + * Feed sync response data into the OlmMachine and process outgoing requests. + * + * @param {Object} syncData + * @param {Array} syncData.toDeviceEvents - to_device.events from sync response + * @param {Object} syncData.deviceLists - device_lists from sync response + * @param {Object} syncData.oneTimeKeyCounts - device_one_time_keys_count from sync response + * @param {Array} [syncData.unusedFallbackKeys] - device_unused_fallback_key_types from sync response + */ + async processSyncResponse ({ toDeviceEvents, deviceLists, oneTimeKeyCounts, unusedFallbackKeys }) { + await this.cryptoManager.receiveSyncChanges(toDeviceEvents, deviceLists, oneTimeKeyCounts, unusedFallbackKeys) + await this.processOutgoingRequests() + } + + /** + * Decrypt a single room event. + * + * Returns a transformed event with the decrypted type and content merged + * onto the original envelope, or null if decryption fails. + * + * @param {Object} event - The raw m.room.encrypted event + * @param {string} roomId + * @returns {Promise} The decrypted event or null + */ + async decryptEvent (event, roomId) { + const decrypted = await this.cryptoManager.decryptRoomEvent(event, roomId) + if (decrypted) { + return { + ...event, + type: decrypted.event.type, + content: decrypted.event.content, + decrypted: true + } + } + getLogger().warn('Could not decrypt event in room', roomId, event.event_id) + return null + } + + /** + * Register a room as encrypted with the OlmMachine. + * + * @param {string} roomId + * @param {Object} [encryptionContent] - Content of the m.room.encryption state event + */ + async registerRoom (roomId, encryptionContent) { + await this.cryptoManager.setRoomEncryption(roomId, encryptionContent) + } + + /** + * Full encrypt ceremony for a room event. + * + * Performs the complete sequence: track users → query device keys → + * process outgoing → claim missing Olm sessions → share Megolm room key → + * process outgoing → encrypt the event. + * + * The caller provides memberIds (fetched from the Matrix API). The facade + * does NOT fetch members itself — that is the caller's responsibility. + * + * @param {string} roomId + * @param {string} eventType + * @param {Object} content + * @param {string[]} memberIds - Room members' user IDs + * @returns {Promise} Encrypted content to send as m.room.encrypted + */ + async encryptEvent (roomId, eventType, content, memberIds) { + const log = getLogger() + + // 1. Track users and explicitly query their device keys + await this.cryptoManager.updateTrackedUsers(memberIds) + const keysQueryRequest = await this.cryptoManager.queryKeysForUsers(memberIds) + if (keysQueryRequest) { + log.debug('E2EE: querying device keys for', memberIds.length, 'users') + const queryResponse = await this.httpAPI.sendOutgoingCryptoRequest(keysQueryRequest) + await this.cryptoManager.markRequestAsSent(keysQueryRequest.id, keysQueryRequest.type, queryResponse) + } + + // 2. Process any other pending outgoing requests + await this.processOutgoingRequests() + + // 3. Claim missing Olm sessions + const claimRequest = await this.cryptoManager.getMissingSessions(memberIds) + if (claimRequest) { + log.debug('E2EE: claiming missing Olm sessions') + const claimResponse = await this.httpAPI.sendOutgoingCryptoRequest(claimRequest) + await this.cryptoManager.markRequestAsSent(claimRequest.id, claimRequest.type, claimResponse) + } + + // 4. Share Megolm session key with all room members' devices + const shareRequests = await this.cryptoManager.shareRoomKey(roomId, memberIds) + log.debug('E2EE: shareRoomKey returned', shareRequests.length, 'to_device requests') + for (const req of shareRequests) { + try { + const body = JSON.parse(req.body) + const reqEventType = req.event_type || req.eventType || 'unknown' + log.debug(`E2EE: to_device type=${reqEventType}`) + if (body.messages) { + for (const [userId, devices] of Object.entries(body.messages)) { + for (const [deviceId] of Object.entries(devices)) { + log.debug(`E2EE: → ${userId} / ${deviceId}`) + } + } + } + } catch { /* ignore parse errors */ } + const resp = await this.httpAPI.sendOutgoingCryptoRequest(req) + await this.cryptoManager.markRequestAsSent(req.id, req.type, resp) + } + + // 5. Process any remaining outgoing requests + await this.processOutgoingRequests() + + // 6. Encrypt the actual message + const encrypted = await this.cryptoManager.encryptRoomEvent(roomId, eventType, content) + log.debug('E2EE: message encrypted for room', roomId) + return encrypted + } + + /** + * Share historical Megolm session keys for a room with specific users. + * + * Fully self-contained: handles track → queryKeys → claimSessions → + * export → olm-encrypt → sendToDevice for each target user. + * + * @param {string} roomId + * @param {string[]} userIds - Target users to share keys with + */ + async shareHistoricalKeys (roomId, userIds) { + const log = getLogger() + + try { + for (const userId of userIds) { + try { + // Ensure we have the user's device keys + await this.cryptoManager.updateTrackedUsers([userId]) + const keysQueryRequest = await this.cryptoManager.queryKeysForUsers([userId]) + if (keysQueryRequest) { + const queryResponse = await this.httpAPI.sendOutgoingCryptoRequest(keysQueryRequest) + await this.cryptoManager.markRequestAsSent(keysQueryRequest.id, keysQueryRequest.type, queryResponse) + } + + // Establish Olm sessions if needed + const claimRequest = await this.cryptoManager.getMissingSessions([userId]) + if (claimRequest) { + const claimResponse = await this.httpAPI.sendOutgoingCryptoRequest(claimRequest) + await this.cryptoManager.markRequestAsSent(claimRequest.id, claimRequest.type, claimResponse) + } + + // Export and share historical keys + const { toDeviceMessages, keyCount } = await this.cryptoManager.shareHistoricalRoomKeys(roomId, userId) + if (keyCount > 0) { + const txnId = `odin_keyshare_${Date.now()}_${Math.random().toString(36).slice(2)}` + await this.httpAPI.sendToDevice('m.room.encrypted', txnId, toDeviceMessages) + log.info(`Shared ${keyCount} historical keys with ${userId} for room ${roomId}`) + } + } catch (err) { + log.warn(`Failed to share historical keys with ${userId}: ${err.message}`) + } + } + } catch (err) { + log.warn(`Failed to share historical keys for room ${roomId}: ${err.message}`) + } + } +} + +export { CryptoFacade } diff --git a/src/http-api.mjs b/src/http-api.mjs index 71484ad..f3770dc 100644 --- a/src/http-api.mjs +++ b/src/http-api.mjs @@ -445,22 +445,6 @@ HttpAPI.prototype.sendOutgoingCryptoRequest = async function (request) { } } -/** - * Process all outgoing requests from the CryptoManager. - * @param {import('./crypto.mjs').CryptoManager} cryptoManager - */ -HttpAPI.prototype.processOutgoingCryptoRequests = async function (cryptoManager) { - const requests = await cryptoManager.outgoingRequests() - for (const request of requests) { - try { - const response = await this.sendOutgoingCryptoRequest(request) - await cryptoManager.markRequestAsSent(request.id, request.type, response) - } catch (error) { - getLogger().error('Failed to process outgoing crypto request:', error.message) - } - } -} - export { HttpAPI } diff --git a/src/project.mjs b/src/project.mjs index 93a6197..8c900c9 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -16,15 +16,15 @@ const M_ROOM_MEMBER = 'm.room.member' const MAX_MESSAGE_SIZE = 56 * 1024 /** - * + * * @param {Object} apis * @property {StructureAPI} structureAPI */ -const Project = function ({ structureAPI, timelineAPI, commandAPI, cryptoManager }) { +const Project = function ({ structureAPI, timelineAPI, commandAPI, crypto = {} }) { this.structureAPI = structureAPI this.timelineAPI = timelineAPI this.commandAPI = commandAPI - this.cryptoManager = cryptoManager || null + this.crypto = crypto this.idMapping = new Map() this.idMapping.remember = function (upstream, downstream) { @@ -41,7 +41,7 @@ const Project = function ({ structureAPI, timelineAPI, commandAPI, cryptoManager } /** - * @description + * @description * @typedef {Object} ProjectStructure * @property {string} id - project id * @property {string} name - project name @@ -56,11 +56,11 @@ const Project = function ({ structureAPI, timelineAPI, commandAPI, cryptoManager /** * @description Retrieves the project hierarchy from the Matrix server and fills the * wellKnown mapping between ODIN and Matrix IDs. - * @param {*} param0 + * @param {*} param0 * @returns {ProjectStructure} */ Project.prototype.hydrate = async function ({ id, upstreamId }) { - + const hierarchy = await this.structureAPI.project(upstreamId) if (!hierarchy) return @@ -71,17 +71,17 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { Object.values(hierarchy.layers).forEach(layer => { this.idMapping.remember(layer.room_id, layer.id) }) - // Register encrypted rooms with the CryptoManager - if (this.cryptoManager) { + // Register encrypted rooms + if (this.crypto.isEnabled) { const allRooms = { ...hierarchy.layers } for (const [roomId, room] of Object.entries(allRooms)) { if (room.encryption) { - await this.cryptoManager.setRoomEncryption(roomId, room.encryption) + await this.crypto.registerRoom(roomId, room.encryption) } } // Also check the space itself if (hierarchy.encryption) { - await this.cryptoManager.setRoomEncryption(upstreamId, hierarchy.encryption) + await this.crypto.registerRoom(upstreamId, hierarchy.encryption) } } @@ -101,7 +101,7 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { self: layer.powerlevel.self.name, default: layer.powerlevel.default.name }, - topic: layer.topic + topic: layer.topic })), invitations: hierarchy.candidates.map(candidate => ({ id: Base64.encodeURI(candidate.id), @@ -109,7 +109,7 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { topic: candidate.topic })) } - + return projectStructure } @@ -119,7 +119,7 @@ Project.prototype.shareLayer = async function (layerId, name, description, optio return } const layer = await this.structureAPI.createLayer(layerId, name, description, undefined, options) - + await this.structureAPI.addLayerToProject(this.idMapping.get(this.projectId), layer.globalId) this.idMapping.remember(layerId, layer.globalId) @@ -134,16 +134,16 @@ Project.prototype.shareLayer = async function (layerId, name, description, optio } Project.prototype.joinLayer = async function (layerId) { - + const upstreamId = this.idMapping.get(layerId) || (Base64.isValid(layerId) ? Base64.decode(layerId) : layerId) - + await this.structureAPI.join(upstreamId) - const room = await this.structureAPI.getLayer(upstreamId) + const room = await this.structureAPI.getLayer(upstreamId) this.idMapping.remember(room.id, room.room_id) // Register encryption if applicable (needed before content can be decrypted) - if (this.cryptoManager && room.encryption) { - await this.cryptoManager.setRoomEncryption(room.room_id, room.encryption) + if (this.crypto.isEnabled && room.encryption) { + await this.crypto.registerRoom(room.room_id, room.encryption) } // Mark for sync-gated content fetch: content will be loaded once the room @@ -171,67 +171,21 @@ Project.prototype.joinLayer = async function (layerId) { * @param {string} layerId - the local layer id */ Project.prototype.shareHistoricalKeys = function (layerId) { - if (!this.cryptoManager) return + if (!this.crypto.isEnabled) return const roomId = this.idMapping.get(layerId) if (!roomId) return this.commandAPI.schedule([async () => { - await this._shareHistoricalKeysWithProjectMembers(roomId) - }]) -} - -/** - * @private - */ -Project.prototype._shareHistoricalKeysWithProjectMembers = async function (roomId, targetUserIds) { - if (!this.cryptoManager) return - const log = getLogger() - const myUserId = this.timelineAPI.credentials().user_id - - try { - // If no specific targets, get all project members - let userIds = targetUserIds - if (!userIds) { - const projectRoomId = this.idMapping.get(this.projectId) - const members = await this.commandAPI.httpAPI.members(projectRoomId) - userIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(id => id !== myUserId) - } + const myUserId = this.timelineAPI.credentials().user_id + const projectRoomId = this.idMapping.get(this.projectId) + const members = await this.commandAPI.httpAPI.members(projectRoomId) + const userIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(id => id !== myUserId) if (userIds.length === 0) return - - for (const userId of userIds) { - try { - // Ensure we have the user's device keys - await this.cryptoManager.updateTrackedUsers([userId]) - const keysQueryRequest = await this.cryptoManager.queryKeysForUsers([userId]) - if (keysQueryRequest) { - const queryResponse = await this.commandAPI.httpAPI.sendOutgoingCryptoRequest(keysQueryRequest) - await this.cryptoManager.markRequestAsSent(keysQueryRequest.id, keysQueryRequest.type, queryResponse) - } - - // Establish Olm sessions if needed - const claimRequest = await this.cryptoManager.getMissingSessions([userId]) - if (claimRequest) { - const claimResponse = await this.commandAPI.httpAPI.sendOutgoingCryptoRequest(claimRequest) - await this.cryptoManager.markRequestAsSent(claimRequest.id, claimRequest.type, claimResponse) - } - - // Export and share historical keys - const { toDeviceMessages, keyCount } = await this.cryptoManager.shareHistoricalRoomKeys(roomId, userId) - if (keyCount > 0) { - const txnId = `odin_keyshare_${Date.now()}_${Math.random().toString(36).slice(2)}` - await this.commandAPI.httpAPI.sendToDevice('m.room.encrypted', txnId, toDeviceMessages) - log.info(`Shared ${keyCount} historical keys with ${userId} for room ${roomId}`) - } - } catch (err) { - log.warn(`Failed to share historical keys with ${userId}: ${err.message}`) - } - } - } catch (err) { - log.warn(`Failed to share historical keys for room ${roomId}: ${err.message}`) - } + await this.crypto.shareHistoricalKeys(roomId, userIds) + }]) } Project.prototype.leaveLayer = async function (layerId) { @@ -264,9 +218,9 @@ Project.prototype.setDefaultRole = async function (layerId, role) { Project.prototype.roles = Object.fromEntries(Object.keys(power.ROLES.LAYER).map(k =>[k, k])) Project.prototype.content = async function (layerId) { - const filter = { + const filter = { lazy_load_members: true, // improve performance - limit: 1000, + limit: 1000, types: [ODINv2_MESSAGE_TYPE] // No not_senders filter: on (re-)join we need ALL events // including our own to reconstruct the full layer state. @@ -275,7 +229,7 @@ Project.prototype.content = async function (layerId) { const upstreamId = this.idMapping.get(layerId) const content = await this.timelineAPI.content(upstreamId, filter) const operations = content.events - .map(event => + .map(event => JSON.parse(Base64.decode(event.content.content)) ) .flat() @@ -296,7 +250,7 @@ Project.prototype.__post = async function (layerId, operations, messageType) { const right = split(ops.slice(half)) return [left, right] } - + const collect = splittedOperations => { if (Array.isArray(splittedOperations[0]) === false) return [splittedOperations] return [...collect(splittedOperations[0]), ...collect(splittedOperations[1])] @@ -319,7 +273,7 @@ Project.prototype.start = async function (streamToken, handler = {}) { Within a project we are only interested in * a new layer has been added to the project >> m.space.child * an existing layer has been renamed >> m.room.name - * a payload message has been posted in the layer >> io.syncpoint.odin.operation + * a payload message has been posted in the layer >> io.syncpoint.odin.operation */ const EVENT_TYPES = [ M_ROOM_NAME, @@ -329,15 +283,15 @@ Project.prototype.start = async function (streamToken, handler = {}) { ODINv2_MESSAGE_TYPE ] - const filter = { + const filter = { account_data: { not_types: [ '*' ] }, room: { - timeline: { + timeline: { lazy_load_members: true, // improve performance - limit: 1000, - types: EVENT_TYPES, + limit: 1000, + types: EVENT_TYPES, not_senders: [ this.timelineAPI.credentials().user_id ], // NO events if the current user is the sender rooms: Array.from(this.idMapping.keys()).filter(key => key.startsWith('!')) }, @@ -346,7 +300,7 @@ Project.prototype.start = async function (streamToken, handler = {}) { } } } - + return filter } @@ -361,13 +315,13 @@ Project.prototype.start = async function (streamToken, handler = {}) { const streamHandler = wrap(handler) this.stream = this.timelineAPI.stream(streamToken, filterProvider) for await (const chunk of this.stream) { - + if (chunk instanceof Error) { await streamHandler.error(chunk) continue } - /* + /* Just store the next batch value no matter if we will process the stream any further. If any of the following functions runs into an error the erroneous chunk will get skipped during the next streamToken updated. @@ -398,7 +352,7 @@ Project.prototype.start = async function (streamToken, handler = {}) { Object.entries(chunk.events).forEach(async ([roomId, content]) => { if (isChildAdded(content)) { - /* + /* If a chunk for a room contains a m.space.child event we need to request the details for each child. m.space.child can only be received for the project (space) itself @@ -410,14 +364,14 @@ Project.prototype.start = async function (streamToken, handler = {}) { getLogger().warn('Received m.space.child but child room not found') return } - + await streamHandler.invited({ id: Base64.encodeURI(childRoom.id), name: childRoom.name, topic: childRoom.topic }) - } - + } + if (isLayerRenamed(content)) { const renamed = content .filter(event => event.type === M_ROOM_NAME) @@ -427,9 +381,9 @@ Project.prototype.start = async function (streamToken, handler = {}) { name: event.content.name } )) - - await streamHandler.renamed(renamed) - } + + await streamHandler.renamed(renamed) + } if (isPowerlevelChanged(content)) { const role = content @@ -440,9 +394,9 @@ Project.prototype.start = async function (streamToken, handler = {}) { id: this.idMapping.get(roomId), role: { self: powerlevel.self.name, - default: powerlevel.default.name + default: powerlevel.default.name } - } + } }) await streamHandler.roleChanged(role) } @@ -460,7 +414,7 @@ Project.prototype.start = async function (streamToken, handler = {}) { // Safety net: share historical keys with newly joined members. // Primary key sharing happens at share/invite time (see shareLayer), // but this catches keys created between share and join. - if (this.cryptoManager) { + if (this.crypto.isEnabled) { const myUserId = this.timelineAPI.credentials().user_id const newJoinUserIds = content .filter(event => event.type === M_ROOM_MEMBER) @@ -469,7 +423,7 @@ Project.prototype.start = async function (streamToken, handler = {}) { .map(event => event.state_key) if (newJoinUserIds.length > 0) { - await this._shareHistoricalKeysWithProjectMembers(roomId, newJoinUserIds) + await this.crypto.shareHistoricalKeys(roomId, newJoinUserIds) } } } @@ -479,13 +433,13 @@ Project.prototype.start = async function (streamToken, handler = {}) { .filter(event => event.type === ODINv2_MESSAGE_TYPE) .map(event => JSON.parse(Base64.decode(event.content.content))) .flat() - + await streamHandler.received({ id: this.idMapping.get(roomId), operations } ) - } + } }) } diff --git a/src/timeline-api.mjs b/src/timeline-api.mjs index 5bf5933..06d0a47 100644 --- a/src/timeline-api.mjs +++ b/src/timeline-api.mjs @@ -50,13 +50,14 @@ function applyPostDecryptTypeFilter (roomEvents, originalTypes) { /** * @param {import('./http-api.mjs').HttpAPI} httpApi - * @param {Object} [crypto] - Optional crypto context - * @param {import('./crypto.mjs').CryptoManager} [crypto.cryptoManager] - * @param {import('./http-api.mjs').HttpAPI} [crypto.httpAPI] + * @param {Object} [options={}] - Optional crypto callbacks + * @param {Function} [options.onSyncResponse] - async (syncData) => void — feed sync data into crypto + * @param {Function} [options.decryptEvent] - async (event, roomId) => decryptedEvent | null */ -const TimelineAPI = function (httpApi, crypto) { +const TimelineAPI = function (httpApi, options = {}) { this.httpApi = httpApi - this.crypto = crypto || null + this.onSyncResponse = options.onSyncResponse || null + this.decryptEvent = options.decryptEvent || null } TimelineAPI.prototype.credentials = function () { @@ -69,7 +70,7 @@ TimelineAPI.prototype.content = async function (roomId, filter, from) { // Augment the filter for crypto: add m.room.encrypted to types let effectiveFilter = filter let originalTypes = null - if (this.crypto && filter?.types && !filter.types.includes(M_ROOM_ENCRYPTED)) { + if (this.decryptEvent && filter?.types && !filter.types.includes(M_ROOM_ENCRYPTED)) { effectiveFilter = { ...filter, types: [...filter.types, M_ROOM_ENCRYPTED] } originalTypes = filter.types } @@ -77,21 +78,12 @@ TimelineAPI.prototype.content = async function (roomId, filter, from) { const result = await this.catchUp(roomId, null, null, 'f', effectiveFilter) // Decrypt + post-filter - if (this.crypto && result.events) { - const { cryptoManager } = this.crypto - const log = getLogger() + if (this.decryptEvent && result.events) { for (let i = 0; i < result.events.length; i++) { if (result.events[i].type === M_ROOM_ENCRYPTED) { - const decrypted = await cryptoManager.decryptRoomEvent(result.events[i], roomId) + const decrypted = await this.decryptEvent(result.events[i], roomId) if (decrypted) { - result.events[i] = { - ...result.events[i], - type: decrypted.event.type, - content: decrypted.event.content, - decrypted: true - } - } else { - log.warn('Could not decrypt event in room', roomId, result.events[i].event_id) + result.events[i] = decrypted } } } @@ -108,8 +100,8 @@ TimelineAPI.prototype.content = async function (roomId, filter, from) { TimelineAPI.prototype.syncTimeline = async function(since, filter, timeout = 0) { /* We want the complete timeline for all rooms that we have already joined. Thus we get the most recent - events and then iterate over partial results until we filled the gap. The order of the events shall be - oldes first. + events and then iterate over partial results until we filled the gap. The order of the events shall be + oldes first. All events regarding invited rooms will not be catched up since we are typically interested in invitations and name changes only. @@ -118,25 +110,23 @@ TimelineAPI.prototype.syncTimeline = async function(since, filter, timeout = 0) // When crypto is active, inject 'm.room.encrypted' into the server-side filter // so encrypted events are not silently dropped. The original types are preserved // for post-decryption client-side filtering. - const effectiveFilter = this.crypto ? augmentFilterForCrypto(filter) : filter + const effectiveFilter = this.decryptEvent ? augmentFilterForCrypto(filter) : filter const originalTypes = effectiveFilter?.room?.timeline?._originalTypes || null const events = {} - // for catching up + // for catching up const jobs = {} const syncResult = await this.httpApi.sync(since, effectiveFilter, timeout) // Feed crypto state from sync response - if (this.crypto) { - const { cryptoManager, httpAPI } = this.crypto + if (this.onSyncResponse) { const toDeviceEvents = syncResult.to_device?.events || [] const deviceLists = syncResult.device_lists || {} const oneTimeKeyCounts = syncResult.device_one_time_keys_count || {} const unusedFallbackKeys = syncResult.device_unused_fallback_key_types || undefined - await cryptoManager.receiveSyncChanges(toDeviceEvents, deviceLists, oneTimeKeyCounts, unusedFallbackKeys) - await httpAPI.processOutgoingCryptoRequests(cryptoManager) + await this.onSyncResponse({ toDeviceEvents, deviceLists, oneTimeKeyCounts, unusedFallbackKeys }) } const stateEvents = {} @@ -165,7 +155,7 @@ TimelineAPI.prototype.syncTimeline = async function(since, filter, timeout = 0) const catchUp = await Promise.all( Object.entries(jobs).map(([roomId, prev_batch]) => this.catchUp(roomId, syncResult.next_batch, prev_batch, 'b', effectiveFilter?.room?.timeline)) ) - /* + /* Since we walk backwards ('b') in time we need to append the events at the head of the array in order to maintain the chronological order (oldest first). */ @@ -174,22 +164,13 @@ TimelineAPI.prototype.syncTimeline = async function(since, filter, timeout = 0) }) // Decrypt encrypted events if crypto is available - if (this.crypto) { - const { cryptoManager } = this.crypto - const log = getLogger() + if (this.decryptEvent) { for (const [roomId, roomEvents] of Object.entries(events)) { for (let i = 0; i < roomEvents.length; i++) { if (roomEvents[i].type === M_ROOM_ENCRYPTED) { - const decrypted = await cryptoManager.decryptRoomEvent(roomEvents[i], roomId) + const decrypted = await this.decryptEvent(roomEvents[i], roomId) if (decrypted) { - roomEvents[i] = { - ...roomEvents[i], - type: decrypted.event.type, - content: decrypted.event.content, - decrypted: true - } - } else { - log.warn('Could not decrypt event in room', roomId, roomEvents[i].event_id) + roomEvents[i] = decrypted } } } @@ -219,7 +200,7 @@ TimelineAPI.prototype.syncTimeline = async function(since, filter, timeout = 0) TimelineAPI.prototype.catchUp = async function (roomId, lastKnownStreamToken, currentStreamToken, dir = 'b', filter = {}) { - const queryOptions = { + const queryOptions = { filter, dir, to: lastKnownStreamToken, @@ -228,8 +209,8 @@ TimelineAPI.prototype.catchUp = async function (roomId, lastKnownStreamToken, cu // Properties "from" and "limited" will be modified during catchUp-phase const pagination = { - from: currentStreamToken, - limited: true + from: currentStreamToken, + limited: true } // The order of events is newest to oldest since we move backwards on the timeline. @@ -255,12 +236,12 @@ TimelineAPI.prototype.catchUp = async function (roomId, lastKnownStreamToken, cu TimelineAPI.prototype.stream = async function* (since, filterProvider, signal = (new AbortController()).signal) { - - + + let streamToken = since let retryCounter = 0 - + while (!signal.aborted) { try { await chill(retryCounter) @@ -274,10 +255,10 @@ TimelineAPI.prototype.stream = async function* (since, filterProvider, signal = } catch (error) { retryCounter++ yield new Error(error) - } + } } } export { TimelineAPI -} \ No newline at end of file +} From 5f7ff9920a09d11ba7e3218b510ff70ae479e2d6 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 18:49:09 +0100 Subject: [PATCH 02/19] refactor: extract logger in CommandAPI.run() to single declaration --- src/command-api.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/command-api.mjs b/src/command-api.mjs index 82838ab..3550046 100644 --- a/src/command-api.mjs +++ b/src/command-api.mjs @@ -52,6 +52,7 @@ class CommandAPI { if (this.controller) return this.controller = new AbortController() + const log = getLogger() let retryCounter = 0 let entry @@ -73,7 +74,6 @@ class CommandAPI { // Encrypt outgoing message events if crypto is available if (this.encryptEvent && functionName === 'sendMessageEvent') { const [roomId, eventType, content, ...rest] = params - const log = getLogger() try { const members = await this.httpAPI.members(roomId) const memberIds = (members.chunk || []) @@ -89,11 +89,9 @@ class CommandAPI { await this.httpAPI[functionName].apply(this.httpAPI, params) await this.scheduledCalls.acknowledge(key) - const log = getLogger() log.debug('Command sent:', functionName) retryCounter = 0 } catch (error) { - const log = getLogger() log.warn('Command failed:', error.message) if (error.response?.statusCode === 403) { log.error('Command forbidden:', entry.command[0], error.response.body) From b9ad29742d1af006ce366e4cb39efe91d9ec211f Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 19:04:22 +0100 Subject: [PATCH 03/19] fix: update E2E tests for crypto-facade API changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../content-after-join-high-level.test.mjs | 2 +- test-e2e/content-after-join.test.mjs | 28 +++++++++++--- test-e2e/matrix-client-api.test.mjs | 38 +++++++++++++------ test-e2e/sas-verification.test.mjs | 14 +++++-- test-e2e/sync-gated-content.test.mjs | 28 +++++++++++--- 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/test-e2e/content-after-join-high-level.test.mjs b/test-e2e/content-after-join-high-level.test.mjs index ec0e082..53a47d2 100644 --- a/test-e2e/content-after-join-high-level.test.mjs +++ b/test-e2e/content-after-join-high-level.test.mjs @@ -86,7 +86,7 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const db = createDB() - const commandAPI = new CommandAPI(httpAPI, null, db) + const commandAPI = new CommandAPI(httpAPI, { db }) const timelineAPI = new TimelineAPI(httpAPI) return { diff --git a/test-e2e/content-after-join.test.mjs b/test-e2e/content-after-join.test.mjs index 9f84cf3..3bf4f91 100644 --- a/test-e2e/content-after-join.test.mjs +++ b/test-e2e/content-after-join.test.mjs @@ -22,6 +22,7 @@ import { StructureAPI } from '../src/structure-api.mjs' import { CommandAPI } from '../src/command-api.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { CryptoManager } from '../src/crypto.mjs' +import { CryptoFacade } from '../src/crypto-facade.mjs' import { Project } from '../src/project.mjs' import { ProjectList } from '../src/project-list.mjs' import { setLogger } from '../src/logger.mjs' @@ -78,16 +79,31 @@ async function registerUser (username, deviceId) { } } +async function processOutgoingRequests (httpAPI, crypto) { + const requests = await crypto.outgoingRequests() + for (const request of requests) { + const response = await httpAPI.sendOutgoingCryptoRequest(request) + await crypto.markRequestAsSent(request.id, request.type, response) + } +} + async function buildStack (credentials) { const httpAPI = new HttpAPI(credentials) const crypto = new CryptoManager() await crypto.initialize(credentials.user_id, credentials.device_id) - await httpAPI.processOutgoingCryptoRequests(crypto) + await processOutgoingRequests(httpAPI, crypto) const structureAPI = new StructureAPI(httpAPI) const db = createDB() - const commandAPI = new CommandAPI(httpAPI, crypto, db) - const timelineAPI = new TimelineAPI(httpAPI, { cryptoManager: crypto, httpAPI }) + const facade = new CryptoFacade(crypto, httpAPI) + const commandAPI = new CommandAPI(httpAPI, { + encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), + db + }) + const timelineAPI = new TimelineAPI(httpAPI, { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) + }) return { httpAPI, crypto, structureAPI, commandAPI, timelineAPI } } @@ -176,14 +192,14 @@ describe('Content after Join', function () { aSync.to_device?.events || [], aSync.device_lists || {}, aSync.device_one_time_keys_count || {}, [] ) - await alice.httpAPI.processOutgoingCryptoRequests(alice.crypto) + await processOutgoingRequests(alice.httpAPI, alice.crypto) const bSync = await bob.httpAPI.sync(undefined, undefined, 0) await bob.crypto.receiveSyncChanges( bSync.to_device?.events || [], bSync.device_lists || {}, bSync.device_one_time_keys_count || {}, [] ) - await bob.httpAPI.processOutgoingCryptoRequests(bob.crypto) + await processOutgoingRequests(bob.httpAPI, bob.crypto) // === Step 4: Alice posts content to the layer === console.log('\n--- Step 4: Alice posts content ---') @@ -231,7 +247,7 @@ describe('Content after Join', function () { bSync2.to_device?.events || [], bSync2.device_lists || {}, bSync2.device_one_time_keys_count || {}, [] ) - await bob.httpAPI.processOutgoingCryptoRequests(bob.crypto) + await processOutgoingRequests(bob.httpAPI, bob.crypto) console.log('Bob synced and processed to_device events') // Register encryption for Bob diff --git a/test-e2e/matrix-client-api.test.mjs b/test-e2e/matrix-client-api.test.mjs index 0229359..ebb4e8b 100644 --- a/test-e2e/matrix-client-api.test.mjs +++ b/test-e2e/matrix-client-api.test.mjs @@ -18,6 +18,7 @@ import { StructureAPI } from '../src/structure-api.mjs' import { CommandAPI } from '../src/command-api.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { CryptoManager } from '../src/crypto.mjs' +import { CryptoFacade } from '../src/crypto-facade.mjs' import { setLogger } from '../src/logger.mjs' import levelup from 'levelup' @@ -67,6 +68,14 @@ async function registerUser (username, deviceId) { } } +async function processOutgoingRequests (httpAPI, crypto) { + const requests = await crypto.outgoingRequests() + for (const request of requests) { + const response = await httpAPI.sendOutgoingCryptoRequest(request) + await crypto.markRequestAsSent(request.id, request.type, response) + } +} + /** Build the full API stack as ODIN does it. */ async function buildStack (credentials) { const httpAPI = new HttpAPI(credentials) @@ -74,11 +83,18 @@ async function buildStack (credentials) { await crypto.initialize(credentials.user_id, credentials.device_id) // Upload device keys (same as ODIN does on project open) - await httpAPI.processOutgoingCryptoRequests(crypto) + await processOutgoingRequests(httpAPI, crypto) const structureAPI = new StructureAPI(httpAPI) - const commandAPI = new CommandAPI(httpAPI, crypto, createDB()) - const timelineAPI = new TimelineAPI(httpAPI, { cryptoManager: crypto, httpAPI }) + const facade = new CryptoFacade(crypto, httpAPI) + const commandAPI = new CommandAPI(httpAPI, { + encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), + db: createDB() + }) + const timelineAPI = new TimelineAPI(httpAPI, { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) + }) return { httpAPI, crypto, structureAPI, commandAPI, timelineAPI } } @@ -142,8 +158,8 @@ describe('matrix-client-api E2EE Integration', function () { describe('Layer 1: HttpAPI + CryptoManager', function () { - it('device keys should be on the server after processOutgoingCryptoRequests()', async () => { - // Bob queries Alice's keys — verifies that HttpAPI.processOutgoingCryptoRequests() worked + it('device keys should be on the server after processOutgoingRequests()', async () => { + // Bob queries Alice's keys — verifies that processOutgoingRequests() worked const result = await bob.httpAPI.client.post('v3/keys/query', { json: { device_keys: { [aliceCreds.user_id]: [] } } }).json() @@ -233,14 +249,14 @@ describe('matrix-client-api E2EE Integration', function () { aSync.to_device?.events || [], aSync.device_lists || {}, aSync.device_one_time_keys_count || {}, [] ) - await alice.httpAPI.processOutgoingCryptoRequests(alice.crypto) + await processOutgoingRequests(alice.httpAPI, alice.crypto) const bSync = await bob.httpAPI.sync(undefined, undefined, 0) await bob.crypto.receiveSyncChanges( bSync.to_device?.events || [], bSync.device_lists || {}, bSync.device_one_time_keys_count || {}, [] ) - await bob.httpAPI.processOutgoingCryptoRequests(bob.crypto) + await processOutgoingRequests(bob.httpAPI, bob.crypto) }) it('should encrypt and send via schedule() + run()', async () => { @@ -299,7 +315,7 @@ describe('matrix-client-api E2EE Integration', function () { aSync.to_device?.events || [], aSync.device_lists || {}, aSync.device_one_time_keys_count || {}, [] ) - await alice.httpAPI.processOutgoingCryptoRequests(alice.crypto) + await processOutgoingRequests(alice.httpAPI, alice.crypto) aliceSyncToken = aSync.next_batch const bSync = await bob.httpAPI.sync(undefined, undefined, 0) @@ -307,7 +323,7 @@ describe('matrix-client-api E2EE Integration', function () { bSync.to_device?.events || [], bSync.device_lists || {}, bSync.device_one_time_keys_count || {}, [] ) - await bob.httpAPI.processOutgoingCryptoRequests(bob.crypto) + await processOutgoingRequests(bob.httpAPI, bob.crypto) // Alice sends an encrypted message via CommandAPI alice.commandAPI.schedule([ @@ -367,14 +383,14 @@ describe('matrix-client-api E2EE Integration', function () { aSync.to_device?.events || [], aSync.device_lists || {}, aSync.device_one_time_keys_count || {}, [] ) - await alice.httpAPI.processOutgoingCryptoRequests(alice.crypto) + await processOutgoingRequests(alice.httpAPI, alice.crypto) const bSync = await bob.httpAPI.sync(undefined, undefined, 0) await bob.crypto.receiveSyncChanges( bSync.to_device?.events || [], bSync.device_lists || {}, bSync.device_one_time_keys_count || {}, [] ) - await bob.httpAPI.processOutgoingCryptoRequests(bob.crypto) + await processOutgoingRequests(bob.httpAPI, bob.crypto) // 5. CommandAPI: Alice sends 2 ODIN operations alice.commandAPI.schedule([ diff --git a/test-e2e/sas-verification.test.mjs b/test-e2e/sas-verification.test.mjs index ea9bfd7..a9d3581 100644 --- a/test-e2e/sas-verification.test.mjs +++ b/test-e2e/sas-verification.test.mjs @@ -31,6 +31,14 @@ setLogger({ error: (...args) => console.error('[ERROR]', ...args) }) +async function processOutgoingRequests (httpAPI, crypto) { + const requests = await crypto.outgoingRequests() + for (const request of requests) { + const response = await httpAPI.sendOutgoingCryptoRequest(request) + await crypto.markRequestAsSent(request.id, request.type, response) + } +} + async function registerUser (username, deviceId) { const res = await fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, { method: 'POST', @@ -62,7 +70,7 @@ async function syncAndProcess (httpAPI, crypto, since) { syncResult.device_one_time_keys_count || {}, syncResult.device_unused_fallback_key_types || [] ) - await httpAPI.processOutgoingCryptoRequests(crypto) + await processOutgoingRequests(httpAPI, crypto) return syncResult.next_batch } @@ -98,11 +106,11 @@ describe('SAS Verification', function () { aliceCrypto = new CryptoManager() await aliceCrypto.initialize(aliceCreds.user_id, aliceCreds.device_id) - await aliceHTTP.processOutgoingCryptoRequests(aliceCrypto) + await processOutgoingRequests(aliceHTTP, aliceCrypto) bobCrypto = new CryptoManager() await bobCrypto.initialize(bobCreds.user_id, bobCreds.device_id) - await bobHTTP.processOutgoingCryptoRequests(bobCrypto) + await processOutgoingRequests(bobHTTP, bobCrypto) // Create a shared room so they discover each other's devices const room = await aliceHTTP.createRoom({ diff --git a/test-e2e/sync-gated-content.test.mjs b/test-e2e/sync-gated-content.test.mjs index ad78c2c..908555f 100644 --- a/test-e2e/sync-gated-content.test.mjs +++ b/test-e2e/sync-gated-content.test.mjs @@ -22,6 +22,7 @@ import { HttpAPI } from '../src/http-api.mjs' import { StructureAPI } from '../src/structure-api.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { CryptoManager } from '../src/crypto.mjs' +import { CryptoFacade } from '../src/crypto-facade.mjs' import { setLogger } from '../src/logger.mjs' import { Base64 } from 'js-base64' @@ -69,10 +70,27 @@ async function registerUser (username, deviceId) { function buildAPIs (credentials, crypto = null) { const httpAPI = new HttpAPI(credentials) const structureAPI = new StructureAPI(httpAPI) - const timelineAPI = new TimelineAPI(httpAPI, crypto ? { cryptoManager: crypto, httpAPI } : null) + let timelineAPI + if (crypto) { + const facade = new CryptoFacade(crypto, httpAPI) + timelineAPI = new TimelineAPI(httpAPI, { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) + }) + } else { + timelineAPI = new TimelineAPI(httpAPI) + } return { httpAPI, structureAPI, timelineAPI } } +async function processOutgoingRequests (httpAPI, crypto) { + const requests = await crypto.outgoingRequests() + for (const request of requests) { + const response = await httpAPI.sendOutgoingCryptoRequest(request) + await crypto.markRequestAsSent(request.id, request.type, response) + } +} + /** * Post ODIN operations to a room. If crypto is provided, encrypts the event. */ @@ -90,7 +108,7 @@ async function postOperations (httpAPI, roomId, operations, crypto = null, membe } const encrypted = await crypto.encryptRoomEvent(roomId, ODIN_OP_TYPE, eventContent) await httpAPI.sendMessageEvent(roomId, 'm.room.encrypted', encrypted) - await httpAPI.processOutgoingCryptoRequests(crypto) + await processOutgoingRequests(httpAPI, crypto) } else { await httpAPI.sendMessageEvent(roomId, ODIN_OP_TYPE, eventContent) } @@ -108,7 +126,7 @@ async function doSync (httpAPI, crypto, since, timeout = 0) { syncResult.device_one_time_keys_count || {}, syncResult.device_unused_fallback_key_types || [] ) - await httpAPI.processOutgoingCryptoRequests(crypto) + await processOutgoingRequests(httpAPI, crypto) } return syncResult } @@ -166,8 +184,8 @@ describe('Sync-Gated Content (E2E)', function () { bob = buildAPIs(bobCreds, bobCrypto) // Initial key upload - await alice.httpAPI.processOutgoingCryptoRequests(aliceCrypto) - await bob.httpAPI.processOutgoingCryptoRequests(bobCrypto) + await processOutgoingRequests(alice.httpAPI, aliceCrypto) + await processOutgoingRequests(bob.httpAPI, bobCrypto) }) after(async function () { From 3add6fe9a1dece8427140b6559ecd01f57742f69 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 19:06:48 +0100 Subject: [PATCH 04/19] feat: room member cache to avoid per-message HTTP lookups Co-Authored-By: Claude Opus 4.6 (1M context) --- index.mjs | 17 +++++++++++ src/command-api.mjs | 17 +++++++---- src/project.mjs | 51 +++++++++++++++++++++++++++---- src/room-members.mjs | 71 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 src/room-members.mjs diff --git a/index.mjs b/index.mjs index 61c8125..091e50d 100644 --- a/index.mjs +++ b/index.mjs @@ -9,6 +9,7 @@ import { setLogger, LEVELS, consoleLogger, noopLogger } from './src/logger.mjs' import { chill } from './src/convenience.mjs' import { CryptoManager, TrustRequirement, VerificationMethod, VerificationRequestPhase } from './src/crypto.mjs' import { CryptoFacade } from './src/crypto-facade.mjs' +import { RoomMemberCache } from './src/room-members.mjs' /* connect() resolves if the home_server can be connected. It does @@ -122,6 +123,20 @@ const MatrixClient = (loginData) => { const credentials = mostRecentCredentials ? mostRecentCredentials : (await HttpAPI.loginWithPassword(loginData)) const httpAPI = new HttpAPI(credentials) const facade = await getCrypto(httpAPI) + const memberCache = new RoomMemberCache() + + const getMemberIds = async (roomId) => { + const cached = memberCache.get(roomId) + if (cached) return cached + const members = await httpAPI.members(roomId) + const ids = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + memberCache.set(roomId, ids) + return ids + } + const projectParams = { structureAPI: new StructureAPI(httpAPI), timelineAPI: new TimelineAPI(httpAPI, facade ? { @@ -132,8 +147,10 @@ const MatrixClient = (loginData) => { encryptEvent: facade ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) : null, + getMemberIds, db: loginData.db }), + memberCache, crypto: facade ? { isEnabled: true, registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), diff --git a/src/command-api.mjs b/src/command-api.mjs index 3550046..f742b0d 100644 --- a/src/command-api.mjs +++ b/src/command-api.mjs @@ -6,11 +6,13 @@ class CommandAPI { * @param {import('./http-api.mjs').HttpAPI} httpAPI * @param {Object} [options={}] * @param {Function} [options.encryptEvent] - async (roomId, eventType, content, memberIds) => encryptedContent + * @param {Function} [options.getMemberIds] - async (roomId) => string[] — cached member lookup with HTTP fallback * @param {Object} [options.db] - A levelup-compatible database instance for persistent queue storage */ constructor (httpAPI, options = {}) { this.httpAPI = httpAPI this.encryptEvent = options.encryptEvent || null + this.getMemberIds = options.getMemberIds || null this.scheduledCalls = new FIFO(options.db) } @@ -75,11 +77,16 @@ class CommandAPI { if (this.encryptEvent && functionName === 'sendMessageEvent') { const [roomId, eventType, content, ...rest] = params try { - const members = await this.httpAPI.members(roomId) - const memberIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(Boolean) + let memberIds + if (this.getMemberIds) { + memberIds = await this.getMemberIds(roomId) + } else { + const members = await this.httpAPI.members(roomId) + memberIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + } const encrypted = await this.encryptEvent(roomId, eventType, content, memberIds) params = [roomId, 'm.room.encrypted', encrypted, ...rest] } catch (encryptError) { diff --git a/src/project.mjs b/src/project.mjs index 8c900c9..a3c3128 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -20,10 +20,11 @@ const MAX_MESSAGE_SIZE = 56 * 1024 * @param {Object} apis * @property {StructureAPI} structureAPI */ -const Project = function ({ structureAPI, timelineAPI, commandAPI, crypto = {} }) { +const Project = function ({ structureAPI, timelineAPI, commandAPI, memberCache, crypto = {} }) { this.structureAPI = structureAPI this.timelineAPI = timelineAPI this.commandAPI = commandAPI + this.memberCache = memberCache || null this.crypto = crypto this.idMapping = new Map() @@ -85,6 +86,18 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { } } + if (this.memberCache) { + const allRoomIds = [upstreamId, ...Object.keys(hierarchy.layers)] + for (const roomId of allRoomIds) { + const members = await this.commandAPI.httpAPI.members(roomId) + const memberIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + this.memberCache.set(roomId, memberIds) + } + } + const projectStructure = { id, name: hierarchy.name, @@ -146,6 +159,15 @@ Project.prototype.joinLayer = async function (layerId) { await this.crypto.registerRoom(room.room_id, room.encryption) } + if (this.memberCache) { + const members = await this.commandAPI.httpAPI.members(room.room_id) + const memberIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + this.memberCache.set(room.room_id, memberIds) + } + // Mark for sync-gated content fetch: content will be loaded once the room // appears in a sync response, not immediately after join. this.pendingContent.add(room.room_id) @@ -177,11 +199,17 @@ Project.prototype.shareHistoricalKeys = function (layerId) { this.commandAPI.schedule([async () => { const myUserId = this.timelineAPI.credentials().user_id const projectRoomId = this.idMapping.get(this.projectId) - const members = await this.commandAPI.httpAPI.members(projectRoomId) - const userIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(id => id !== myUserId) + + let userIds + if (this.memberCache && this.memberCache.has(projectRoomId)) { + userIds = this.memberCache.get(projectRoomId).filter(id => id !== myUserId) + } else { + const members = await this.commandAPI.httpAPI.members(projectRoomId) + userIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(id => id !== myUserId) + } if (userIds.length === 0) return await this.crypto.shareHistoricalKeys(roomId, userIds) @@ -193,6 +221,7 @@ Project.prototype.leaveLayer = async function (layerId) { const layer = await this.structureAPI.getLayer(upstreamId) await this.structureAPI.leave(upstreamId) + if (this.memberCache) this.memberCache.remove(upstreamId) this.idMapping.forget(layerId) this.idMapping.forget(upstreamId) @@ -402,6 +431,16 @@ Project.prototype.start = async function (streamToken, handler = {}) { } if (isMembershipChanged(content)) { + if (this.memberCache) { + for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { + if (event.content.membership === 'join') { + this.memberCache.addMember(roomId, event.state_key) + } else if (['leave', 'ban'].includes(event.content.membership)) { + this.memberCache.removeMember(roomId, event.state_key) + } + } + } + const membership = content .filter(event => event.type === M_ROOM_MEMBER) .map(event => ({ diff --git a/src/room-members.mjs b/src/room-members.mjs new file mode 100644 index 0000000..824dd87 --- /dev/null +++ b/src/room-members.mjs @@ -0,0 +1,71 @@ +/** + * In-memory cache for room membership. + * Event-driven: updated by sync stream membership events, not by polling. + */ +class RoomMemberCache { + constructor () { + this.rooms = new Map() + } + + /** + * Set the full member list for a room (initial population). + * @param {string} roomId + * @param {string[]} memberIds + */ + set (roomId, memberIds) { + this.rooms.set(roomId, new Set(memberIds)) + } + + /** + * Get cached member IDs for a room. + * @param {string} roomId + * @returns {string[]|null} Member IDs or null if room is not cached + */ + get (roomId) { + const members = this.rooms.get(roomId) + return members ? Array.from(members) : null + } + + /** + * Add a member to a room (on join event). + * @param {string} roomId + * @param {string} userId + */ + addMember (roomId, userId) { + let members = this.rooms.get(roomId) + if (!members) { + members = new Set() + this.rooms.set(roomId, members) + } + members.add(userId) + } + + /** + * Remove a member from a room (on leave/kick/ban event). + * @param {string} roomId + * @param {string} userId + */ + removeMember (roomId, userId) { + const members = this.rooms.get(roomId) + if (members) members.delete(userId) + } + + /** + * Whether a room has cached membership data. + * @param {string} roomId + * @returns {boolean} + */ + has (roomId) { + return this.rooms.has(roomId) + } + + /** + * Remove a room from the cache (on leave). + * @param {string} roomId + */ + remove (roomId) { + this.rooms.delete(roomId) + } +} + +export { RoomMemberCache } From 6906c61028d1aab98f3dac53c8b3d72df3f64cf5 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 19:32:27 +0100 Subject: [PATCH 05/19] refactor: make getMemberIds a mandatory parameter of CommandAPI --- index.mjs | 3 +- specs/crypto-facade-refactoring.md | 200 ++++++++++++++ specs/test-fix-and-member-cache.md | 244 ++++++++++++++++++ src/command-api.mjs | 17 +- .../content-after-join-high-level.test.mjs | 9 +- test-e2e/content-after-join.test.mjs | 9 +- test-e2e/matrix-client-api.test.mjs | 9 +- test-e2e/project-join-content.test.mjs | 234 +++++++++++++++++ 8 files changed, 707 insertions(+), 18 deletions(-) create mode 100644 specs/crypto-facade-refactoring.md create mode 100644 specs/test-fix-and-member-cache.md create mode 100644 test-e2e/project-join-content.test.mjs diff --git a/index.mjs b/index.mjs index 091e50d..499d597 100644 --- a/index.mjs +++ b/index.mjs @@ -143,11 +143,10 @@ const MatrixClient = (loginData) => { onSyncResponse: (data) => facade.processSyncResponse(data), decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) } : {}), - commandAPI: new CommandAPI(httpAPI, { + commandAPI: new CommandAPI(httpAPI, getMemberIds, { encryptEvent: facade ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) : null, - getMemberIds, db: loginData.db }), memberCache, diff --git a/specs/crypto-facade-refactoring.md b/specs/crypto-facade-refactoring.md new file mode 100644 index 0000000..a98a3c0 --- /dev/null +++ b/specs/crypto-facade-refactoring.md @@ -0,0 +1,200 @@ +# Crypto Facade Refactoring Spec + +## Goal + +Eliminate direct `CryptoManager` usage from `TimelineAPI`, `CommandAPI`, and `Project`. +Introduce a `CryptoFacade` that encapsulates all crypto orchestration in one place. +No API module should know that a `CryptoManager` exists. + +## Motivation + +Currently, the 7-step encrypt ceremony (trackUsers → queryKeys → claimSessions → shareRoomKey → encrypt → processOutgoing) is duplicated across `CommandAPI.run()` and `Project._shareHistoricalKeysWithProjectMembers()`. Every module directly calls `cryptoManager.decryptRoomEvent()`, `cryptoManager.receiveSyncChanges()`, `cryptoManager.setRoomEncryption()`, etc. This violates separation of concerns and makes the code fragile. + +## Architecture + +### New: `src/crypto-facade.mjs` + +A facade that owns the `CryptoManager` and `HttpAPI` references and exposes high-level operations: + +```javascript +class CryptoFacade { + constructor(cryptoManager, httpAPI) + + // Feed sync response data into the OlmMachine and process outgoing requests + async processSyncResponse({ toDeviceEvents, deviceLists, oneTimeKeyCounts, unusedFallbackKeys }) + + // Decrypt a single room event. Returns the transformed event or null. + async decryptEvent(event, roomId) + + // Register a room as encrypted + async registerRoom(roomId, encryptionContent) + + // Full encrypt ceremony: track users, query keys, claim sessions, share room key, encrypt + // The caller provides memberIds (fetched from the Matrix API). + async encryptEvent(roomId, eventType, content, memberIds) + + // Share historical Megolm session keys with specific users + // Handles: track → queryKeys → claimSessions → export → olm-encrypt → sendToDevice + async shareHistoricalKeys(roomId, userIds) + + // Process all pending outgoing crypto requests + async processOutgoingRequests() +} +``` + +**Key design decisions:** +- `encryptEvent()` receives `memberIds` as a parameter. The facade does NOT fetch members itself — that would mean reaching into the HTTP layer for room membership, which is the caller's responsibility. +- `shareHistoricalKeys()` is fully self-contained: it handles the complete flow including `sendToDevice`. The caller just says "share keys for this room with these users." +- `processOutgoingCryptoRequests()` moves from `HttpAPI` into the facade. `HttpAPI.sendOutgoingCryptoRequest()` stays in HttpAPI (it's a pure HTTP concern). + +### Changes to `TimelineAPI` + +**Before:** +```javascript +const TimelineAPI = function (httpApi, crypto) { + this.crypto = crypto || null // { cryptoManager, httpAPI } +} +``` + +**After:** +```javascript +const TimelineAPI = function (httpApi, options = {}) { + this.onSyncResponse = options.onSyncResponse || null // async (syncData) => void + this.decryptEvent = options.decryptEvent || null // async (event, roomId) => decryptedEvent | null +} +``` + +- In `syncTimeline()`: replace `this.crypto` block with `if (this.onSyncResponse) await this.onSyncResponse({...})` +- In `content()` and `syncTimeline()`: replace `cryptoManager.decryptRoomEvent()` calls with `this.decryptEvent(event, roomId)` +- The `augmentFilterForCrypto()` logic stays in TimelineAPI (it's a filter concern, not a crypto concern), but it triggers based on `this.decryptEvent` being set (i.e., crypto is active) rather than `this.crypto`. +- `applyPostDecryptTypeFilter()` stays as-is (pure filter logic). + +### Changes to `CommandAPI` + +**Before:** +```javascript +constructor(httpAPI, cryptoManager, db) +// 60 lines of encrypt ceremony in run() +``` + +**After:** +```javascript +constructor(httpAPI, options = {}) +// options.encryptEvent: async (roomId, eventType, content) => encryptedContent +// options.db: levelup-compatible database +``` + +In `run()`, the entire crypto block (lines 73–130) becomes: +```javascript +if (this.encryptEvent && functionName === 'sendMessageEvent') { + const [roomId, eventType, content, ...rest] = params + try { + const members = await this.httpAPI.members(roomId) + const memberIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + const encrypted = await this.encryptEvent(roomId, eventType, content, memberIds) + params = [roomId, 'm.room.encrypted', encrypted, ...rest] + } catch (encryptError) { + log.warn('Encryption failed, sending unencrypted:', encryptError.message) + } +} +``` + +Note: member fetching stays in CommandAPI because it's the only place that knows *when* to fetch members (at send time). The facade's `encryptEvent()` receives the memberIds. + +### Changes to `Project` + +**Before:** +```javascript +const Project = function ({ structureAPI, timelineAPI, commandAPI, cryptoManager }) +// Direct cryptoManager calls everywhere +``` + +**After:** +```javascript +const Project = function ({ structureAPI, timelineAPI, commandAPI, crypto = {} }) +// crypto.registerRoom: async (roomId, encryptionContent) => void +// crypto.shareHistoricalKeys: async (roomId, userIds) => void +// crypto.isEnabled: boolean +``` + +Changes: +- `this.cryptoManager` → `this.crypto` (the options object with callbacks) +- All `if (this.cryptoManager)` → `if (this.crypto.isEnabled)` +- `this.cryptoManager.setRoomEncryption(roomId, enc)` → `this.crypto.registerRoom(roomId, enc)` +- `this._shareHistoricalKeysWithProjectMembers(roomId, userIds)` → `this.crypto.shareHistoricalKeys(roomId, userIds)` — the entire 50-line method disappears +- `this.shareHistoricalKeys()` (the public method that schedules via commandAPI) uses the crypto callback too + +### Changes to `index.mjs` (MatrixClient) + +The MatrixClient factory is the **single composition root** where everything is wired: + +```javascript +const getCrypto = async (httpAPI) => { + if (!encryption?.enabled) return null + // ... initialize CryptoManager as before ... + return new CryptoFacade(cryptoManager, httpAPI) +} + +// In project(): +const facade = await getCrypto(httpAPI) +const timelineAPI = new TimelineAPI(httpAPI, facade ? { + onSyncResponse: (data) => facade.processSyncResponse(data), + decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) +} : {}) + +const commandAPI = new CommandAPI(httpAPI, { + encryptEvent: facade + ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) + : null, + db: loginData.db +}) + +const project = new Project({ + structureAPI, timelineAPI, commandAPI, + crypto: facade ? { + isEnabled: true, + registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), + shareHistoricalKeys: (roomId, userIds) => facade.shareHistoricalKeys(roomId, userIds) + } : { isEnabled: false } +}) +``` + +### Changes to `HttpAPI` + +- `processOutgoingCryptoRequests(cryptoManager)` is **removed** (it moves to CryptoFacade) +- `sendOutgoingCryptoRequest(request)` **stays** (it's a pure HTTP method) +- The `import { RequestType } from './crypto.mjs'` at the top stays (needed for `sendOutgoingCryptoRequest`) + +### What does NOT change + +- `CryptoManager` class itself — it's a clean OlmMachine wrapper, stays as-is +- `StructureAPI` — has no crypto involvement +- `ProjectList` — has no crypto involvement +- E2E test files — they test through the public MatrixClient API and should continue to work +- The public exports from `index.mjs` (CryptoManager, TrustRequirement, etc. stay exported for direct use) + +## Acceptance Criteria + +1. No module except `CryptoFacade` and `index.mjs` imports from `crypto.mjs` +2. `TimelineAPI`, `CommandAPI`, `Project` have zero references to `CryptoManager` +3. The encrypt ceremony (track → query → claim → share → encrypt → processOutgoing) exists exactly once, in `CryptoFacade.encryptEvent()` +4. The historical key sharing ceremony exists exactly once, in `CryptoFacade.shareHistoricalKeys()` +5. All existing E2E tests pass: `npm run test:e2e` +6. No public API changes (MatrixClient return values, Project methods, etc.) +7. `HttpAPI.processOutgoingCryptoRequests()` is removed +8. Clean separation: each module only knows about its callbacks/options, not the crypto internals + +## Implementation Order + +1. Create `src/crypto-facade.mjs` with full implementation + JSDoc +2. Refactor `TimelineAPI` to use callback options +3. Refactor `CommandAPI` to use callback options +4. Refactor `Project` to use crypto options object +5. Update `index.mjs` wiring +6. Remove `HttpAPI.processOutgoingCryptoRequests()` +7. Run `npx eslint src/` — fix any issues +8. Run E2E tests (if Docker/Tuwunel is available) +9. Commit with message: "refactor: extract CryptoFacade, remove direct CryptoManager coupling" diff --git a/specs/test-fix-and-member-cache.md b/specs/test-fix-and-member-cache.md new file mode 100644 index 0000000..fff8f75 --- /dev/null +++ b/specs/test-fix-and-member-cache.md @@ -0,0 +1,244 @@ +# Task: Fix E2E Tests + Room Member Cache + +## Part 1: Fix E2E Tests + +All E2E tests fail because they use the OLD API signatures that were changed in the crypto-facade refactoring. + +### What changed in the refactoring + +1. **`HttpAPI.processOutgoingCryptoRequests(cryptoManager)` was REMOVED** — this method moved into `CryptoFacade` +2. **`CommandAPI` constructor changed**: old `(httpAPI, cryptoManager, db)` → new `(httpAPI, options = {})` where options has `encryptEvent` and `db` +3. **`TimelineAPI` constructor changed**: old `(httpAPI, { cryptoManager, httpAPI })` → new `(httpAPI, options = {})` where options has `onSyncResponse` and `decryptEvent` callbacks +4. **`Project` constructor changed**: old `cryptoManager` param → new `crypto = {}` with `isEnabled`, `registerRoom`, `shareHistoricalKeys` + +### How to fix each test file + +The tests use CryptoManager directly for low-level crypto operations (which is fine — CryptoManager is still exported). +The problem is they also call methods/constructors that changed. + +**For ALL tests that call `httpAPI.processOutgoingCryptoRequests(crypto)`:** +Import `CryptoFacade` from `../src/crypto-facade.mjs` and use `facade.processOutgoingRequests()` instead. +Or, since these tests are low-level integration tests that test the crypto flow directly, create a small helper: + +```javascript +import { CryptoFacade } from '../src/crypto-facade.mjs' + +// Helper to replace the removed httpAPI.processOutgoingCryptoRequests() +async function processOutgoingRequests (httpAPI, crypto) { + const facade = new CryptoFacade(crypto, httpAPI) + await facade.processOutgoingRequests() +} +``` + +Or even simpler — just inline the logic since it's trivial: +```javascript +async function processOutgoingRequests (httpAPI, crypto) { + const requests = await crypto.outgoingRequests() + for (const request of requests) { + const response = await httpAPI.sendOutgoingCryptoRequest(request) + await crypto.markRequestAsSent(request.id, request.type, response) + } +} +``` + +This is actually **cleaner** because the tests don't need to know about CryptoFacade at all. + +**For tests that use `new CommandAPI(httpAPI, crypto, db)`:** +Change to: `new CommandAPI(httpAPI, { db })` (no encryptEvent needed for most tests that encrypt manually) +OR if the test relies on CommandAPI auto-encrypting: `new CommandAPI(httpAPI, { encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), db })` + +**For tests that use `new TimelineAPI(httpAPI, { cryptoManager: crypto, httpAPI })`:** +Change to: `new TimelineAPI(httpAPI, { onSyncResponse: (data) => facade.processSyncResponse(data), decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) })` + +### Files to fix + +1. **`test-e2e/content-after-join-high-level.test.mjs`** — `CommandAPI(httpAPI, null, db)` → `CommandAPI(httpAPI, { db })` +2. **`test-e2e/content-after-join.test.mjs`** — `processOutgoingCryptoRequests`, `CommandAPI`, `TimelineAPI` signatures +3. **`test-e2e/matrix-client-api.test.mjs`** — same as above +4. **`test-e2e/sas-verification.test.mjs`** — `processOutgoingCryptoRequests` only (no CommandAPI/TimelineAPI) +5. **`test-e2e/sync-gated-content.test.mjs`** — `processOutgoingCryptoRequests`, `TimelineAPI` signature +6. **`test-e2e/e2ee.test.mjs`** — passes already (only uses CryptoManager directly), DO NOT TOUCH +7. **`test-e2e/project-join-content.test.mjs`** — test #4 fails with assertion error, may need separate investigation. Uses the high-level MatrixClient API, so it should work already. Check if it's a pre-existing issue. + +### Important: Do NOT change test logic! +Only update constructor calls and method calls to match the new API. +The test assertions and flow must stay exactly the same. + +--- + +## Part 2: Room Member Cache + +### New file: `src/room-members.mjs` + +```javascript +/** + * In-memory cache for room membership. + * Event-driven: updated by sync stream membership events, not by polling. + */ +class RoomMemberCache { + constructor () { + this.rooms = new Map() // Map> + } + + /** + * Set the full member list for a room (initial population). + * @param {string} roomId + * @param {string[]} memberIds + */ + set (roomId, memberIds) { + this.rooms.set(roomId, new Set(memberIds)) + } + + /** + * Get cached member IDs for a room. + * @param {string} roomId + * @returns {string[]|null} Member IDs or null if room is not cached + */ + get (roomId) { + const members = this.rooms.get(roomId) + return members ? Array.from(members) : null + } + + /** + * Add a member to a room (on join event). + * @param {string} roomId + * @param {string} userId + */ + addMember (roomId, userId) { + let members = this.rooms.get(roomId) + if (!members) { + members = new Set() + this.rooms.set(roomId, members) + } + members.add(userId) + } + + /** + * Remove a member from a room (on leave/kick/ban event). + * @param {string} roomId + * @param {string} userId + */ + removeMember (roomId, userId) { + const members = this.rooms.get(roomId) + if (members) members.delete(userId) + } + + /** + * Whether a room has cached membership data. + * @param {string} roomId + * @returns {boolean} + */ + has (roomId) { + return this.rooms.has(roomId) + } + + /** + * Remove a room from the cache (on leave). + * @param {string} roomId + */ + remove (roomId) { + this.rooms.delete(roomId) + } +} + +export { RoomMemberCache } +``` + +### Changes to `Project` + +1. **Constructor**: Accept `memberCache` in the options +2. **`hydrate()`**: After loading the hierarchy, populate the cache for each layer +3. **`joinLayer()`**: After join, populate the cache for the new room +4. **`start()` → `isMembershipChanged` handler**: Update cache on join/leave events +5. **`leaveLayer()`**: Remove room from cache +6. **`shareHistoricalKeys()`**: Read member IDs from cache instead of `httpAPI.members()` + +### Changes to `CommandAPI` + +1. **Constructor options**: Add `getMemberIds: async (roomId) => string[]` +2. **`run()`**: Use `this.getMemberIds(roomId)` instead of `this.httpAPI.members(roomId)` + filter chain. The callback should fall back to HTTP when cache is empty. + +### Changes to `index.mjs` + +Wire the cache: + +```javascript +const memberCache = new RoomMemberCache() + +// getMemberIds with HTTP fallback +const getMemberIds = async (roomId) => { + const cached = memberCache.get(roomId) + if (cached) return cached + // Fallback: fetch from server (first message before cache is populated) + const members = await httpAPI.members(roomId) + const ids = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + memberCache.set(roomId, ids) + return ids +} + +const commandAPI = new CommandAPI(httpAPI, { + encryptEvent: facade ? (...) => facade.encryptEvent(...) : null, + getMemberIds, + db: loginData.db +}) + +const project = new Project({ + structureAPI, timelineAPI, commandAPI, + memberCache, + crypto: ... +}) +``` + +### Population in `Project.hydrate()` + +After loading the hierarchy, for each layer room, fetch its members and cache them: + +```javascript +// After layer registration, populate member cache +if (this.memberCache) { + const allRoomIds = [upstreamId, ...Object.keys(hierarchy.layers)] + for (const roomId of allRoomIds) { + const members = await this.commandAPI.httpAPI.members(roomId) + const memberIds = (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + this.memberCache.set(roomId, memberIds) + } +} +``` + +### Update in `Project.start()` membership handler + +```javascript +if (isMembershipChanged(content)) { + // ... existing code ... + + // Update member cache + if (this.memberCache) { + for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { + if (event.content.membership === 'join') { + this.memberCache.addMember(roomId, event.state_key) + } else if (['leave', 'ban'].includes(event.content.membership)) { + this.memberCache.removeMember(roomId, event.state_key) + } + } + } +} +``` + +### Implementation Order + +1. Fix all E2E tests first (Part 1) +2. Run tests — they should all pass +3. Implement `src/room-members.mjs` +4. Update `CommandAPI` (getMemberIds callback) +5. Update `Project` (cache population + updates) +6. Update `index.mjs` (wiring) +7. Run `npx eslint src/` — fix issues +8. Run E2E tests again +9. Commit Part 1: "fix: update E2E tests for crypto-facade API changes" +10. Commit Part 2: "feat: room member cache to avoid per-message HTTP lookups" diff --git a/src/command-api.mjs b/src/command-api.mjs index f742b0d..943e183 100644 --- a/src/command-api.mjs +++ b/src/command-api.mjs @@ -4,15 +4,15 @@ import { getLogger } from './logger.mjs' class CommandAPI { /** * @param {import('./http-api.mjs').HttpAPI} httpAPI + * @param {Function} getMemberIds - async (roomId) => string[] — returns joined member IDs for a room * @param {Object} [options={}] * @param {Function} [options.encryptEvent] - async (roomId, eventType, content, memberIds) => encryptedContent - * @param {Function} [options.getMemberIds] - async (roomId) => string[] — cached member lookup with HTTP fallback * @param {Object} [options.db] - A levelup-compatible database instance for persistent queue storage */ - constructor (httpAPI, options = {}) { + constructor (httpAPI, getMemberIds, options = {}) { this.httpAPI = httpAPI + this.getMemberIds = getMemberIds this.encryptEvent = options.encryptEvent || null - this.getMemberIds = options.getMemberIds || null this.scheduledCalls = new FIFO(options.db) } @@ -77,16 +77,7 @@ class CommandAPI { if (this.encryptEvent && functionName === 'sendMessageEvent') { const [roomId, eventType, content, ...rest] = params try { - let memberIds - if (this.getMemberIds) { - memberIds = await this.getMemberIds(roomId) - } else { - const members = await this.httpAPI.members(roomId) - memberIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(Boolean) - } + const memberIds = await this.getMemberIds(roomId) const encrypted = await this.encryptEvent(roomId, eventType, content, memberIds) params = [roomId, 'm.room.encrypted', encrypted, ...rest] } catch (encryptError) { diff --git a/test-e2e/content-after-join-high-level.test.mjs b/test-e2e/content-after-join-high-level.test.mjs index 53a47d2..e972092 100644 --- a/test-e2e/content-after-join-high-level.test.mjs +++ b/test-e2e/content-after-join-high-level.test.mjs @@ -86,7 +86,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const db = createDB() - const commandAPI = new CommandAPI(httpAPI, { db }) + const getMemberIds = async (roomId) => { + const members = await httpAPI.members(roomId) + return (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + } + const commandAPI = new CommandAPI(httpAPI, getMemberIds, { db }) const timelineAPI = new TimelineAPI(httpAPI) return { diff --git a/test-e2e/content-after-join.test.mjs b/test-e2e/content-after-join.test.mjs index 3bf4f91..b741519 100644 --- a/test-e2e/content-after-join.test.mjs +++ b/test-e2e/content-after-join.test.mjs @@ -96,7 +96,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const db = createDB() const facade = new CryptoFacade(crypto, httpAPI) - const commandAPI = new CommandAPI(httpAPI, { + const getMemberIds = async (roomId) => { + const members = await httpAPI.members(roomId) + return (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + } + const commandAPI = new CommandAPI(httpAPI, getMemberIds, { encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), db }) diff --git a/test-e2e/matrix-client-api.test.mjs b/test-e2e/matrix-client-api.test.mjs index ebb4e8b..0194696 100644 --- a/test-e2e/matrix-client-api.test.mjs +++ b/test-e2e/matrix-client-api.test.mjs @@ -87,7 +87,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const facade = new CryptoFacade(crypto, httpAPI) - const commandAPI = new CommandAPI(httpAPI, { + const getMemberIds = async (roomId) => { + const members = await httpAPI.members(roomId) + return (members.chunk || []) + .filter(e => e.content?.membership === 'join') + .map(e => e.state_key) + .filter(Boolean) + } + const commandAPI = new CommandAPI(httpAPI, getMemberIds, { encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), db: createDB() }) diff --git a/test-e2e/project-join-content.test.mjs b/test-e2e/project-join-content.test.mjs new file mode 100644 index 0000000..8477d77 --- /dev/null +++ b/test-e2e/project-join-content.test.mjs @@ -0,0 +1,234 @@ +/** + * E2E Test: Full ODIN flow — join layer and receive content. + * + * Uses ONLY the high-level API (MatrixClient, ProjectList, Project). + * No direct httpAPI, TimelineAPI, or StructureAPI calls. + * + * Flow: + * 1. Alice creates a project and a layer + * 2. Alice posts ODIN operations to the layer + * 3. Alice invites Bob to the project + * 4. Bob joins the project + * 5. Bob hydrates the project, starts the stream + * 6. Bob joins the layer (no explicit content() call) + * 7. Bob's received() handler should get all operations + * + * Prerequisites: + * cd test-e2e && docker compose up -d + * + * Run: + * npm run test:e2e -- --grep "Project Join Content" + */ + +import { describe, it, before, after } from 'mocha' +import assert from 'assert' +import levelup from 'levelup' +import memdown from 'memdown' +import subleveldown from 'subleveldown' +import { MatrixClient, setLogger } from '../index.mjs' + +const HOMESERVER_URL = process.env.HOMESERVER_URL || 'http://localhost:8008' +const suffix = Date.now().toString(36) + +if (!process.env.E2E_DEBUG) { + setLogger({ + info: (...args) => console.log('[INFO]', ...args), + debug: () => {}, + warn: (...args) => console.warn('[WARN]', ...args), + error: (...args) => console.error('[ERROR]', ...args) + }) +} else { + setLogger({ + info: (...args) => console.log('[INFO]', ...args), + debug: (...args) => console.log('[DEBUG]', ...args), + warn: (...args) => console.warn('[WARN]', ...args), + error: (...args) => console.error('[ERROR]', ...args) + }) +} + +/** + * Register a user directly via the Matrix API (test helper only). + */ +async function registerUser (username) { + const res = await fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password: `pass_${username}`, + device_id: `DEVICE_${username}`, + auth: { type: 'm.login.dummy' } + }) + }) + const data = await res.json() + if (data.errcode) throw new Error(`Registration failed: ${data.error}`) + return { + user_id: data.user_id, + access_token: data.access_token, + device_id: data.device_id, + home_server_url: HOMESERVER_URL + } +} + +function createDB () { + const db = levelup(memdown()) + return subleveldown(db, 'command-queue', { valueEncoding: 'json' }) +} + +describe('Project Join Content (E2E)', function () { + this.timeout(120000) + + let aliceCreds, bobCreds + let aliceClient, bobClient + let aliceProjectList, bobProjectList + let aliceProject, bobProject + + const projectLocalId = `project-${suffix}` + const layerLocalId = `layer-${suffix}` + + before(async function () { + // Check if Tuwunel is running + try { + const res = await fetch(`${HOMESERVER_URL}/_matrix/client/versions`) + const data = await res.json() + if (!data.versions) throw new Error('not a Matrix server') + } catch { + this.skip() + } + + // Register users + aliceCreds = await registerUser(`alice_pjc_${suffix}`) + bobCreds = await registerUser(`bob_pjc_${suffix}`) + + // Create MatrixClient instances (no E2EE) + aliceClient = MatrixClient({ + ...aliceCreds, + db: createDB() + }) + bobClient = MatrixClient({ + ...bobCreds, + db: createDB() + }) + }) + + after(async function () { + if (aliceProject) await aliceProject.stop?.() + if (bobProject) await bobProject.stop?.() + }) + + it('Bob receives layer content after joining via stream handler', async function () { + + // === Step 1: Alice sets up ProjectList and creates a project === + console.log('\n--- Step 1: Alice creates project ---') + aliceProjectList = await aliceClient.projectList(aliceCreds) + await aliceProjectList.hydrate() + + const shared = await aliceProjectList.share(projectLocalId, 'Test Project', 'E2E test') + console.log(`Project created: ${shared.upstreamId}`) + + // === Step 2: Alice invites Bob to the project BEFORE creating the layer === + // This way Bob can hydrate and see the layer as invitation. + console.log('\n--- Step 2: Alice invites Bob ---') + await aliceProjectList.invite(projectLocalId, bobCreds.user_id) + console.log('Bob invited') + + // === Step 3: Bob joins the project === + console.log('\n--- Step 3: Bob joins project ---') + bobProjectList = await bobClient.projectList(bobCreds) + await bobProjectList.hydrate() + await bobProjectList.join(projectLocalId) + console.log('Bob joined project') + + // === Step 4: Alice creates layer and posts content === + console.log('\n--- Step 4: Alice creates layer and posts content ---') + aliceProject = await aliceClient.project(aliceCreds) + await aliceProject.hydrate({ id: projectLocalId, upstreamId: shared.upstreamId }) + + const layer = await aliceProject.shareLayer(layerLocalId, 'Test Layer', '') + console.log(`Layer created: ${layer.upstreamId}`) + + // Post ODIN operations via commandAPI + const testOps = [ + { type: 'put', key: 'feature:1', value: { name: 'Tank', sidc: 'SFGPUCA---' } }, + { type: 'put', key: 'feature:2', value: { name: 'HQ', sidc: 'SFGPUH----' } }, + { type: 'put', key: 'feature:3', value: { name: 'Infantry', sidc: 'SFGPUCI---' } } + ] + await aliceProject.post(layerLocalId, testOps) + + // Wait for command queue to drain + await new Promise(resolve => setTimeout(resolve, 3000)) + console.log('Content posted: 3 operations') + + // === Step 5: Bob hydrates the project === + console.log('\n--- Step 5: Bob hydrates project ---') + bobProject = await bobClient.project(bobCreds) + const projectUpstreamId = bobProjectList.wellKnown.get(projectLocalId) + + let projectStructure + for (let attempt = 0; attempt < 10; attempt++) { + projectStructure = await bobProject.hydrate({ id: projectLocalId, upstreamId: projectUpstreamId }) + if (projectStructure.invitations.length > 0) break + console.log(` Hydrate attempt ${attempt + 1}: ${projectStructure.layers.length} layers, ${projectStructure.invitations.length} invitations — retrying...`) + await new Promise(resolve => setTimeout(resolve, 1000)) + } + console.log(`Bob hydrated: ${projectStructure.layers.length} layers, ${projectStructure.invitations.length} invitations`) + assert.ok(projectStructure.invitations.length > 0, 'Bob should see the layer as an invitation') + + // === Step 6: Bob starts the stream and joins the layer === + console.log('\n--- Step 6: Bob starts stream and joins layer ---') + + const receivedOps = [] + let receivedResolve + const receivedPromise = new Promise(resolve => { receivedResolve = resolve }) + + const timeout = setTimeout(() => receivedResolve(), 60000) + + bobProject.start(undefined, { + streamToken: async () => {}, + received: async ({ id, operations }) => { + console.log(` received() called: ${operations.length} operations for layer ${id}`) + receivedOps.push(...operations) + if (receivedOps.length >= 3) { + clearTimeout(timeout) + receivedResolve() + } + }, + invited: async (invitation) => { + console.log(` invited() called: ${invitation.name}`) + }, + renamed: async () => {}, + roleChanged: async () => {}, + membershipChanged: async () => {}, + error: async (err) => { + console.error('Stream error:', err) + } + }) + + // Give the stream a moment to start + await new Promise(resolve => setTimeout(resolve, 2000)) + + // Bob joins the layer — NO content() call after this + const invitation = projectStructure.invitations[0] + console.log(`Joining layer: ${invitation.id} (${invitation.name})`) + const joinedLayer = await bobProject.joinLayer(invitation.id) + console.log(`Joined layer: ${joinedLayer.id}`) + + // === Step 7: Wait for operations to arrive via received() === + console.log('\n--- Step 7: Waiting for operations via received() handler ---') + await receivedPromise + + await bobProject.stop() + + // === Assertions === + console.log(`\nReceived ${receivedOps.length} operations total`) + assert.strictEqual(receivedOps.length, 3, 'Bob should receive all 3 operations') + assert.strictEqual(receivedOps[0].key, 'feature:1') + assert.strictEqual(receivedOps[0].value.name, 'Tank') + assert.strictEqual(receivedOps[1].key, 'feature:2') + assert.strictEqual(receivedOps[1].value.name, 'HQ') + assert.strictEqual(receivedOps[2].key, 'feature:3') + assert.strictEqual(receivedOps[2].value.name, 'Infantry') + + console.log('\n✅ Bob received all layer content via stream handler after join!') + }) +}) From d232233b3db046dcbaa210050186d5456121d3d9 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 19:42:07 +0100 Subject: [PATCH 06/19] refactor: replace memberCache in Project with getMemberIds and onMembershipChanged callbacks --- index.mjs | 9 ++++++++- src/project.mjs | 48 ++++++++---------------------------------------- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/index.mjs b/index.mjs index 499d597..f0de512 100644 --- a/index.mjs +++ b/index.mjs @@ -149,7 +149,14 @@ const MatrixClient = (loginData) => { : null, db: loginData.db }), - memberCache, + getMemberIds, + onMembershipChanged: (roomId, userId, membership) => { + if (membership === 'join') { + memberCache.addMember(roomId, userId) + } else if (membership === 'leave' || membership === 'ban') { + memberCache.removeMember(roomId, userId) + } + }, crypto: facade ? { isEnabled: true, registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), diff --git a/src/project.mjs b/src/project.mjs index a3c3128..b8e6a60 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -20,11 +20,12 @@ const MAX_MESSAGE_SIZE = 56 * 1024 * @param {Object} apis * @property {StructureAPI} structureAPI */ -const Project = function ({ structureAPI, timelineAPI, commandAPI, memberCache, crypto = {} }) { +const Project = function ({ structureAPI, timelineAPI, commandAPI, getMemberIds, onMembershipChanged, crypto = {} }) { this.structureAPI = structureAPI this.timelineAPI = timelineAPI this.commandAPI = commandAPI - this.memberCache = memberCache || null + this.getMemberIds = getMemberIds + this.onMembershipChanged = onMembershipChanged || null this.crypto = crypto this.idMapping = new Map() @@ -86,17 +87,7 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { } } - if (this.memberCache) { - const allRoomIds = [upstreamId, ...Object.keys(hierarchy.layers)] - for (const roomId of allRoomIds) { - const members = await this.commandAPI.httpAPI.members(roomId) - const memberIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(Boolean) - this.memberCache.set(roomId, memberIds) - } - } + const projectStructure = { id, @@ -159,15 +150,6 @@ Project.prototype.joinLayer = async function (layerId) { await this.crypto.registerRoom(room.room_id, room.encryption) } - if (this.memberCache) { - const members = await this.commandAPI.httpAPI.members(room.room_id) - const memberIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(Boolean) - this.memberCache.set(room.room_id, memberIds) - } - // Mark for sync-gated content fetch: content will be loaded once the room // appears in a sync response, not immediately after join. this.pendingContent.add(room.room_id) @@ -199,17 +181,8 @@ Project.prototype.shareHistoricalKeys = function (layerId) { this.commandAPI.schedule([async () => { const myUserId = this.timelineAPI.credentials().user_id const projectRoomId = this.idMapping.get(this.projectId) - - let userIds - if (this.memberCache && this.memberCache.has(projectRoomId)) { - userIds = this.memberCache.get(projectRoomId).filter(id => id !== myUserId) - } else { - const members = await this.commandAPI.httpAPI.members(projectRoomId) - userIds = (members.chunk || []) - .filter(e => e.content?.membership === 'join') - .map(e => e.state_key) - .filter(id => id !== myUserId) - } + const allMembers = await this.getMemberIds(projectRoomId) + const userIds = allMembers.filter(id => id !== myUserId) if (userIds.length === 0) return await this.crypto.shareHistoricalKeys(roomId, userIds) @@ -221,7 +194,6 @@ Project.prototype.leaveLayer = async function (layerId) { const layer = await this.structureAPI.getLayer(upstreamId) await this.structureAPI.leave(upstreamId) - if (this.memberCache) this.memberCache.remove(upstreamId) this.idMapping.forget(layerId) this.idMapping.forget(upstreamId) @@ -431,13 +403,9 @@ Project.prototype.start = async function (streamToken, handler = {}) { } if (isMembershipChanged(content)) { - if (this.memberCache) { + if (this.onMembershipChanged) { for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { - if (event.content.membership === 'join') { - this.memberCache.addMember(roomId, event.state_key) - } else if (['leave', 'ban'].includes(event.content.membership)) { - this.memberCache.removeMember(roomId, event.state_key) - } + this.onMembershipChanged(roomId, event.state_key, event.content.membership) } } From 8c1f2b555759141afa9cf604d3a7ff2521efbebb Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 19:47:11 +0100 Subject: [PATCH 07/19] fix: clean up member cache on leaveLayer via onRoomLeft callback --- index.mjs | 3 +++ src/project.mjs | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/index.mjs b/index.mjs index f0de512..76387be 100644 --- a/index.mjs +++ b/index.mjs @@ -157,6 +157,9 @@ const MatrixClient = (loginData) => { memberCache.removeMember(roomId, userId) } }, + onRoomLeft: (roomId) => { + memberCache.remove(roomId) + }, crypto: facade ? { isEnabled: true, registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), diff --git a/src/project.mjs b/src/project.mjs index b8e6a60..fd6be6c 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -20,12 +20,13 @@ const MAX_MESSAGE_SIZE = 56 * 1024 * @param {Object} apis * @property {StructureAPI} structureAPI */ -const Project = function ({ structureAPI, timelineAPI, commandAPI, getMemberIds, onMembershipChanged, crypto = {} }) { +const Project = function ({ structureAPI, timelineAPI, commandAPI, getMemberIds, onMembershipChanged, onRoomLeft, crypto = {} }) { this.structureAPI = structureAPI this.timelineAPI = timelineAPI this.commandAPI = commandAPI this.getMemberIds = getMemberIds this.onMembershipChanged = onMembershipChanged || null + this.onRoomLeft = onRoomLeft || null this.crypto = crypto this.idMapping = new Map() @@ -194,6 +195,7 @@ Project.prototype.leaveLayer = async function (layerId) { const layer = await this.structureAPI.getLayer(upstreamId) await this.structureAPI.leave(upstreamId) + if (this.onRoomLeft) this.onRoomLeft(upstreamId) this.idMapping.forget(layerId) this.idMapping.forget(upstreamId) From 38d9d1adf931a7ca3d033077e7d285c7b0c0ce1c Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 20:02:16 +0100 Subject: [PATCH 08/19] refactor: replace callback trio with self-sufficient RoomMemberCache --- index.mjs | 26 +++++--------------------- src/project.mjs | 18 +++++++++--------- src/room-members.mjs | 41 ++++++++++++++++------------------------- 3 files changed, 30 insertions(+), 55 deletions(-) diff --git a/index.mjs b/index.mjs index 76387be..cee42cf 100644 --- a/index.mjs +++ b/index.mjs @@ -123,19 +123,13 @@ const MatrixClient = (loginData) => { const credentials = mostRecentCredentials ? mostRecentCredentials : (await HttpAPI.loginWithPassword(loginData)) const httpAPI = new HttpAPI(credentials) const facade = await getCrypto(httpAPI) - const memberCache = new RoomMemberCache() - - const getMemberIds = async (roomId) => { - const cached = memberCache.get(roomId) - if (cached) return cached + const memberCache = new RoomMemberCache(async (roomId) => { const members = await httpAPI.members(roomId) - const ids = (members.chunk || []) + return (members.chunk || []) .filter(e => e.content?.membership === 'join') .map(e => e.state_key) .filter(Boolean) - memberCache.set(roomId, ids) - return ids - } + }) const projectParams = { structureAPI: new StructureAPI(httpAPI), @@ -143,23 +137,13 @@ const MatrixClient = (loginData) => { onSyncResponse: (data) => facade.processSyncResponse(data), decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) } : {}), - commandAPI: new CommandAPI(httpAPI, getMemberIds, { + commandAPI: new CommandAPI(httpAPI, (roomId) => memberCache.getMembers(roomId), { encryptEvent: facade ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) : null, db: loginData.db }), - getMemberIds, - onMembershipChanged: (roomId, userId, membership) => { - if (membership === 'join') { - memberCache.addMember(roomId, userId) - } else if (membership === 'leave' || membership === 'ban') { - memberCache.removeMember(roomId, userId) - } - }, - onRoomLeft: (roomId) => { - memberCache.remove(roomId) - }, + memberCache, crypto: facade ? { isEnabled: true, registerRoom: (roomId, enc) => facade.registerRoom(roomId, enc), diff --git a/src/project.mjs b/src/project.mjs index fd6be6c..af5c27a 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -20,13 +20,11 @@ const MAX_MESSAGE_SIZE = 56 * 1024 * @param {Object} apis * @property {StructureAPI} structureAPI */ -const Project = function ({ structureAPI, timelineAPI, commandAPI, getMemberIds, onMembershipChanged, onRoomLeft, crypto = {} }) { +const Project = function ({ structureAPI, timelineAPI, commandAPI, memberCache, crypto = {} }) { this.structureAPI = structureAPI this.timelineAPI = timelineAPI this.commandAPI = commandAPI - this.getMemberIds = getMemberIds - this.onMembershipChanged = onMembershipChanged || null - this.onRoomLeft = onRoomLeft || null + this.memberCache = memberCache this.crypto = crypto this.idMapping = new Map() @@ -182,7 +180,7 @@ Project.prototype.shareHistoricalKeys = function (layerId) { this.commandAPI.schedule([async () => { const myUserId = this.timelineAPI.credentials().user_id const projectRoomId = this.idMapping.get(this.projectId) - const allMembers = await this.getMemberIds(projectRoomId) + const allMembers = await this.memberCache.getMembers(projectRoomId) const userIds = allMembers.filter(id => id !== myUserId) if (userIds.length === 0) return @@ -195,7 +193,7 @@ Project.prototype.leaveLayer = async function (layerId) { const layer = await this.structureAPI.getLayer(upstreamId) await this.structureAPI.leave(upstreamId) - if (this.onRoomLeft) this.onRoomLeft(upstreamId) + this.memberCache.remove(upstreamId) this.idMapping.forget(layerId) this.idMapping.forget(upstreamId) @@ -405,9 +403,11 @@ Project.prototype.start = async function (streamToken, handler = {}) { } if (isMembershipChanged(content)) { - if (this.onMembershipChanged) { - for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { - this.onMembershipChanged(roomId, event.state_key, event.content.membership) + for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { + if (event.content.membership === 'join') { + this.memberCache.addMember(roomId, event.state_key) + } else if (event.content.membership === 'leave' || event.content.membership === 'ban') { + this.memberCache.removeMember(roomId, event.state_key) } } diff --git a/src/room-members.mjs b/src/room-members.mjs index 824dd87..04b8758 100644 --- a/src/room-members.mjs +++ b/src/room-members.mjs @@ -1,29 +1,29 @@ /** * In-memory cache for room membership. - * Event-driven: updated by sync stream membership events, not by polling. + * + * Self-sufficient: fetches members from the server on cache miss via the + * provided fetch callback. Updated by sync stream membership events. + * + * @param {Function} fetchMembers - async (roomId) => string[] — fetches joined member IDs from the server */ class RoomMemberCache { - constructor () { + constructor (fetchMembers) { this.rooms = new Map() + this.fetchMembers = fetchMembers } /** - * Set the full member list for a room (initial population). + * Get member IDs for a room. Fetches from server on cache miss. * @param {string} roomId - * @param {string[]} memberIds + * @returns {Promise} */ - set (roomId, memberIds) { + async getMembers (roomId) { + if (this.rooms.has(roomId)) { + return Array.from(this.rooms.get(roomId)) + } + const memberIds = await this.fetchMembers(roomId) this.rooms.set(roomId, new Set(memberIds)) - } - - /** - * Get cached member IDs for a room. - * @param {string} roomId - * @returns {string[]|null} Member IDs or null if room is not cached - */ - get (roomId) { - const members = this.rooms.get(roomId) - return members ? Array.from(members) : null + return memberIds } /** @@ -51,16 +51,7 @@ class RoomMemberCache { } /** - * Whether a room has cached membership data. - * @param {string} roomId - * @returns {boolean} - */ - has (roomId) { - return this.rooms.has(roomId) - } - - /** - * Remove a room from the cache (on leave). + * Discard a room entirely (on leave). * @param {string} roomId */ remove (roomId) { From 09fe80d72c2a228f80491c3d7886614ce98a5bd1 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 20:09:00 +0100 Subject: [PATCH 09/19] refactor: filter M_ROOM_MEMBER events once, use result for cache, handler and key sharing --- src/project.mjs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/project.mjs b/src/project.mjs index af5c27a..01aa9ee 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -403,21 +403,21 @@ Project.prototype.start = async function (streamToken, handler = {}) { } if (isMembershipChanged(content)) { - for (const event of content.filter(e => e.type === M_ROOM_MEMBER)) { + const memberEvents = content.filter(e => e.type === M_ROOM_MEMBER) + + // Update cache and build handler payload in one pass + const membership = memberEvents.map(event => { if (event.content.membership === 'join') { this.memberCache.addMember(roomId, event.state_key) } else if (event.content.membership === 'leave' || event.content.membership === 'ban') { this.memberCache.removeMember(roomId, event.state_key) } - } - - const membership = content - .filter(event => event.type === M_ROOM_MEMBER) - .map(event => ({ + return { id: this.idMapping.get(roomId), membership: event.content.membership, subject: event.state_key - })) + } + }) await streamHandler.membershipChanged(membership) // Safety net: share historical keys with newly joined members. @@ -425,10 +425,8 @@ Project.prototype.start = async function (streamToken, handler = {}) { // but this catches keys created between share and join. if (this.crypto.isEnabled) { const myUserId = this.timelineAPI.credentials().user_id - const newJoinUserIds = content - .filter(event => event.type === M_ROOM_MEMBER) - .filter(event => event.content.membership === 'join') - .filter(event => event.state_key !== myUserId) + const newJoinUserIds = memberEvents + .filter(event => event.content.membership === 'join' && event.state_key !== myUserId) .map(event => event.state_key) if (newJoinUserIds.length > 0) { From fecb0c24a8a87483a6792d3c7927cba4ae984078 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 20:12:53 +0100 Subject: [PATCH 10/19] refactor: separate filter/map, cache update and stream handler into distinct steps --- src/project.mjs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/project.mjs b/src/project.mjs index 01aa9ee..9b4472d 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -403,21 +403,22 @@ Project.prototype.start = async function (streamToken, handler = {}) { } if (isMembershipChanged(content)) { - const memberEvents = content.filter(e => e.type === M_ROOM_MEMBER) - - // Update cache and build handler payload in one pass - const membership = memberEvents.map(event => { - if (event.content.membership === 'join') { - this.memberCache.addMember(roomId, event.state_key) - } else if (event.content.membership === 'leave' || event.content.membership === 'ban') { - this.memberCache.removeMember(roomId, event.state_key) - } - return { + const membership = content + .filter(event => event.type === M_ROOM_MEMBER) + .map(event => ({ id: this.idMapping.get(roomId), membership: event.content.membership, subject: event.state_key + })) + + for (const change of membership) { + if (change.membership === 'join') { + this.memberCache.addMember(roomId, change.subject) + } else if (change.membership === 'leave' || change.membership === 'ban') { + this.memberCache.removeMember(roomId, change.subject) } - }) + } + await streamHandler.membershipChanged(membership) // Safety net: share historical keys with newly joined members. @@ -425,9 +426,9 @@ Project.prototype.start = async function (streamToken, handler = {}) { // but this catches keys created between share and join. if (this.crypto.isEnabled) { const myUserId = this.timelineAPI.credentials().user_id - const newJoinUserIds = memberEvents - .filter(event => event.content.membership === 'join' && event.state_key !== myUserId) - .map(event => event.state_key) + const newJoinUserIds = membership + .filter(m => m.membership === 'join' && m.subject !== myUserId) + .map(m => m.subject) if (newJoinUserIds.length > 0) { await this.crypto.shareHistoricalKeys(roomId, newJoinUserIds) From ec6c36642760306c8d57c9c5f60224ef3eb7735c Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Thu, 19 Mar 2026 22:49:14 +0100 Subject: [PATCH 11/19] added home_server property * required for invitations --- test-e2e/content-after-join.test.mjs | 1 + test-e2e/project-join-content.test.mjs | 1 + test-e2e/sas-verification.test.mjs | 1 + 3 files changed, 3 insertions(+) diff --git a/test-e2e/content-after-join.test.mjs b/test-e2e/content-after-join.test.mjs index b741519..ab950d8 100644 --- a/test-e2e/content-after-join.test.mjs +++ b/test-e2e/content-after-join.test.mjs @@ -75,6 +75,7 @@ async function registerUser (username, deviceId) { user_id: data.user_id, access_token: data.access_token, device_id: data.device_id, + home_server: 'odin.battlefield', home_server_url: HOMESERVER_URL } } diff --git a/test-e2e/project-join-content.test.mjs b/test-e2e/project-join-content.test.mjs index 8477d77..4fd5ad6 100644 --- a/test-e2e/project-join-content.test.mjs +++ b/test-e2e/project-join-content.test.mjs @@ -66,6 +66,7 @@ async function registerUser (username) { user_id: data.user_id, access_token: data.access_token, device_id: data.device_id, + home_server: 'odin.battlefield', home_server_url: HOMESERVER_URL } } diff --git a/test-e2e/sas-verification.test.mjs b/test-e2e/sas-verification.test.mjs index a9d3581..e367b52 100644 --- a/test-e2e/sas-verification.test.mjs +++ b/test-e2e/sas-verification.test.mjs @@ -54,6 +54,7 @@ async function registerUser (username, deviceId) { user_id: data.user_id, access_token: data.access_token, device_id: data.device_id, + home_server: 'odin.battlefield', home_server_url: HOMESERVER_URL } } From 199cffe10453b2a07ac176006e37633844fb3219 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 22:58:46 +0100 Subject: [PATCH 12/19] refactor: pass RoomMemberCache directly to CommandAPI, remove getMemberIds callback --- index.mjs | 2 +- src/command-api.mjs | 8 ++++---- test-e2e/content-after-join-high-level.test.mjs | 7 ++++--- test-e2e/content-after-join.test.mjs | 7 ++++--- test-e2e/matrix-client-api.test.mjs | 7 ++++--- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/index.mjs b/index.mjs index cee42cf..42ceb55 100644 --- a/index.mjs +++ b/index.mjs @@ -137,7 +137,7 @@ const MatrixClient = (loginData) => { onSyncResponse: (data) => facade.processSyncResponse(data), decryptEvent: (event, roomId) => facade.decryptEvent(event, roomId) } : {}), - commandAPI: new CommandAPI(httpAPI, (roomId) => memberCache.getMembers(roomId), { + commandAPI: new CommandAPI(httpAPI, memberCache, { encryptEvent: facade ? (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds) : null, diff --git a/src/command-api.mjs b/src/command-api.mjs index 943e183..b0d776e 100644 --- a/src/command-api.mjs +++ b/src/command-api.mjs @@ -4,14 +4,14 @@ import { getLogger } from './logger.mjs' class CommandAPI { /** * @param {import('./http-api.mjs').HttpAPI} httpAPI - * @param {Function} getMemberIds - async (roomId) => string[] — returns joined member IDs for a room + * @param {import('./room-members.mjs').RoomMemberCache} memberCache * @param {Object} [options={}] * @param {Function} [options.encryptEvent] - async (roomId, eventType, content, memberIds) => encryptedContent * @param {Object} [options.db] - A levelup-compatible database instance for persistent queue storage */ - constructor (httpAPI, getMemberIds, options = {}) { + constructor (httpAPI, memberCache, options = {}) { this.httpAPI = httpAPI - this.getMemberIds = getMemberIds + this.memberCache = memberCache this.encryptEvent = options.encryptEvent || null this.scheduledCalls = new FIFO(options.db) } @@ -77,7 +77,7 @@ class CommandAPI { if (this.encryptEvent && functionName === 'sendMessageEvent') { const [roomId, eventType, content, ...rest] = params try { - const memberIds = await this.getMemberIds(roomId) + const memberIds = await this.memberCache.getMembers(roomId) const encrypted = await this.encryptEvent(roomId, eventType, content, memberIds) params = [roomId, 'm.room.encrypted', encrypted, ...rest] } catch (encryptError) { diff --git a/test-e2e/content-after-join-high-level.test.mjs b/test-e2e/content-after-join-high-level.test.mjs index e972092..a51d7a4 100644 --- a/test-e2e/content-after-join-high-level.test.mjs +++ b/test-e2e/content-after-join-high-level.test.mjs @@ -21,6 +21,7 @@ import assert from 'node:assert/strict' import { HttpAPI } from '../src/http-api.mjs' import { StructureAPI } from '../src/structure-api.mjs' import { CommandAPI } from '../src/command-api.mjs' +import { RoomMemberCache } from '../src/room-members.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { Project } from '../src/project.mjs' import { ProjectList } from '../src/project-list.mjs' @@ -86,14 +87,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const db = createDB() - const getMemberIds = async (roomId) => { + const memberCache = new RoomMemberCache(async (roomId) => { const members = await httpAPI.members(roomId) return (members.chunk || []) .filter(e => e.content?.membership === 'join') .map(e => e.state_key) .filter(Boolean) - } - const commandAPI = new CommandAPI(httpAPI, getMemberIds, { db }) + }) + const commandAPI = new CommandAPI(httpAPI, memberCache, { db }) const timelineAPI = new TimelineAPI(httpAPI) return { diff --git a/test-e2e/content-after-join.test.mjs b/test-e2e/content-after-join.test.mjs index ab950d8..653afcf 100644 --- a/test-e2e/content-after-join.test.mjs +++ b/test-e2e/content-after-join.test.mjs @@ -20,6 +20,7 @@ import assert from 'assert' import { HttpAPI } from '../src/http-api.mjs' import { StructureAPI } from '../src/structure-api.mjs' import { CommandAPI } from '../src/command-api.mjs' +import { RoomMemberCache } from '../src/room-members.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { CryptoManager } from '../src/crypto.mjs' import { CryptoFacade } from '../src/crypto-facade.mjs' @@ -97,14 +98,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const db = createDB() const facade = new CryptoFacade(crypto, httpAPI) - const getMemberIds = async (roomId) => { + const memberCache = new RoomMemberCache(async (roomId) => { const members = await httpAPI.members(roomId) return (members.chunk || []) .filter(e => e.content?.membership === 'join') .map(e => e.state_key) .filter(Boolean) - } - const commandAPI = new CommandAPI(httpAPI, getMemberIds, { + }) + const commandAPI = new CommandAPI(httpAPI, memberCache, { encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), db }) diff --git a/test-e2e/matrix-client-api.test.mjs b/test-e2e/matrix-client-api.test.mjs index 0194696..782e868 100644 --- a/test-e2e/matrix-client-api.test.mjs +++ b/test-e2e/matrix-client-api.test.mjs @@ -16,6 +16,7 @@ import assert from 'assert' import { HttpAPI } from '../src/http-api.mjs' import { StructureAPI } from '../src/structure-api.mjs' import { CommandAPI } from '../src/command-api.mjs' +import { RoomMemberCache } from '../src/room-members.mjs' import { TimelineAPI } from '../src/timeline-api.mjs' import { CryptoManager } from '../src/crypto.mjs' import { CryptoFacade } from '../src/crypto-facade.mjs' @@ -87,14 +88,14 @@ async function buildStack (credentials) { const structureAPI = new StructureAPI(httpAPI) const facade = new CryptoFacade(crypto, httpAPI) - const getMemberIds = async (roomId) => { + const memberCache = new RoomMemberCache(async (roomId) => { const members = await httpAPI.members(roomId) return (members.chunk || []) .filter(e => e.content?.membership === 'join') .map(e => e.state_key) .filter(Boolean) - } - const commandAPI = new CommandAPI(httpAPI, getMemberIds, { + }) + const commandAPI = new CommandAPI(httpAPI, memberCache, { encryptEvent: (roomId, type, content, memberIds) => facade.encryptEvent(roomId, type, content, memberIds), db: createDB() }) From 9fe742bf6949002703bb2ad2b0b04be15c0d3183 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 23:05:42 +0100 Subject: [PATCH 13/19] fix: handle network errors in RoomMemberCache.getMembers, fall back to cached data --- src/room-members.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/room-members.mjs b/src/room-members.mjs index 04b8758..e479c5c 100644 --- a/src/room-members.mjs +++ b/src/room-members.mjs @@ -14,16 +14,19 @@ class RoomMemberCache { /** * Get member IDs for a room. Fetches from server on cache miss. + * On network failure, returns the existing cached members (possibly empty). * @param {string} roomId * @returns {Promise} */ async getMembers (roomId) { - if (this.rooms.has(roomId)) { - return Array.from(this.rooms.get(roomId)) + try { + const memberIds = await this.fetchMembers(roomId) + this.rooms.set(roomId, new Set(memberIds)) + return memberIds + } catch { + const cached = this.rooms.get(roomId) + return cached ? Array.from(cached) : [] } - const memberIds = await this.fetchMembers(roomId) - this.rooms.set(roomId, new Set(memberIds)) - return memberIds } /** From 05036b9637b1a85feb146d6332cefa6f0a16b641 Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 23:10:31 +0100 Subject: [PATCH 14/19] Revert "fix: handle network errors in RoomMemberCache.getMembers, fall back to cached data" This reverts commit 9fe742bf6949002703bb2ad2b0b04be15c0d3183. --- src/room-members.mjs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/room-members.mjs b/src/room-members.mjs index e479c5c..04b8758 100644 --- a/src/room-members.mjs +++ b/src/room-members.mjs @@ -14,19 +14,16 @@ class RoomMemberCache { /** * Get member IDs for a room. Fetches from server on cache miss. - * On network failure, returns the existing cached members (possibly empty). * @param {string} roomId * @returns {Promise} */ async getMembers (roomId) { - try { - const memberIds = await this.fetchMembers(roomId) - this.rooms.set(roomId, new Set(memberIds)) - return memberIds - } catch { - const cached = this.rooms.get(roomId) - return cached ? Array.from(cached) : [] + if (this.rooms.has(roomId)) { + return Array.from(this.rooms.get(roomId)) } + const memberIds = await this.fetchMembers(roomId) + this.rooms.set(roomId, new Set(memberIds)) + return memberIds } /** From 1e3ebdc6462e2d26e11bb107ffb0389e107b474d Mon Sep 17 00:00:00 2001 From: Krapotke Date: Thu, 19 Mar 2026 23:10:40 +0100 Subject: [PATCH 15/19] fix: guard fetchMembers against network errors on cache miss --- src/room-members.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/room-members.mjs b/src/room-members.mjs index 04b8758..4a61076 100644 --- a/src/room-members.mjs +++ b/src/room-members.mjs @@ -21,9 +21,13 @@ class RoomMemberCache { if (this.rooms.has(roomId)) { return Array.from(this.rooms.get(roomId)) } - const memberIds = await this.fetchMembers(roomId) - this.rooms.set(roomId, new Set(memberIds)) - return memberIds + try { + const memberIds = await this.fetchMembers(roomId) + this.rooms.set(roomId, new Set(memberIds)) + return memberIds + } catch { + return [] + } } /** From b72f279ca84420e10dcb3d1696bac280a2321c45 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Thu, 19 Mar 2026 23:30:45 +0100 Subject: [PATCH 16/19] fix: e2e test that uses joinLayer needs to have a value for home_server This values goes into the "via" array that is required for space -> child relationship https://spec.matrix.org/v1.17/client-server-api/#mspacechild-relationship --- test-e2e/sync-gated-content.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/test-e2e/sync-gated-content.test.mjs b/test-e2e/sync-gated-content.test.mjs index 908555f..87882b4 100644 --- a/test-e2e/sync-gated-content.test.mjs +++ b/test-e2e/sync-gated-content.test.mjs @@ -63,6 +63,7 @@ async function registerUser (username, deviceId) { user_id: data.user_id, access_token: data.access_token, device_id: data.device_id, + home_server: 'odin.battlefield', home_server_url: HOMESERVER_URL } } From 7eed9ebabd9c29de8a995a8cc500840291c6484c Mon Sep 17 00:00:00 2001 From: Krapotke Date: Fri, 20 Mar 2026 09:52:11 +0100 Subject: [PATCH 17/19] Remove unused encryptionContent parameter from setRoomEncryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter was passed through the entire call chain (project → crypto-facade → crypto) but never used — the algorithm is hardcoded to MegolmV1AesSha2 via RoomSettings. Also fixed sync-gated-content unit tests: mocks used wrong property name (cryptoManager instead of crypto) and wrong method (setRoomEncryption instead of registerRoom), so the encryption registration path was never actually tested. --- src/crypto-facade.mjs | 5 ++--- src/crypto.mjs | 3 +-- src/project.mjs | 6 +++--- test-e2e/content-after-join.test.mjs | 4 ++-- test-e2e/e2ee.test.mjs | 4 ++-- test-e2e/matrix-client-api.test.mjs | 12 ++++++------ test-e2e/sync-gated-content.test.mjs | 4 ++-- test/crypto.test.mjs | 6 +++--- test/sync-gated-content.test.mjs | 10 ++++++---- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/crypto-facade.mjs b/src/crypto-facade.mjs index 0082e87..91c4d7f 100644 --- a/src/crypto-facade.mjs +++ b/src/crypto-facade.mjs @@ -84,10 +84,9 @@ class CryptoFacade { * Register a room as encrypted with the OlmMachine. * * @param {string} roomId - * @param {Object} [encryptionContent] - Content of the m.room.encryption state event */ - async registerRoom (roomId, encryptionContent) { - await this.cryptoManager.setRoomEncryption(roomId, encryptionContent) + async registerRoom (roomId) { + await this.cryptoManager.setRoomEncryption(roomId) } /** diff --git a/src/crypto.mjs b/src/crypto.mjs index 336c010..95edfcc 100644 --- a/src/crypto.mjs +++ b/src/crypto.mjs @@ -237,9 +237,8 @@ class CryptoManager { * Register a room as encrypted with the OlmMachine. * Must be called when a room with m.room.encryption state is discovered. * @param {string} roomId - * @param {Object} [encryptionContent] - Content of the m.room.encryption state event */ - async setRoomEncryption (roomId, encryptionContent = {}) { + async setRoomEncryption (roomId) { if (!this.olmMachine) throw new Error(NOT_INITIALIZED) const log = getLogger() const settings = new RoomSettings(EncryptionAlgorithm.MegolmV1AesSha2, false, false) diff --git a/src/project.mjs b/src/project.mjs index 9b4472d..1b1b774 100644 --- a/src/project.mjs +++ b/src/project.mjs @@ -77,12 +77,12 @@ Project.prototype.hydrate = async function ({ id, upstreamId }) { const allRooms = { ...hierarchy.layers } for (const [roomId, room] of Object.entries(allRooms)) { if (room.encryption) { - await this.crypto.registerRoom(roomId, room.encryption) + await this.crypto.registerRoom(roomId) } } // Also check the space itself if (hierarchy.encryption) { - await this.crypto.registerRoom(upstreamId, hierarchy.encryption) + await this.crypto.registerRoom(upstreamId) } } @@ -146,7 +146,7 @@ Project.prototype.joinLayer = async function (layerId) { // Register encryption if applicable (needed before content can be decrypted) if (this.crypto.isEnabled && room.encryption) { - await this.crypto.registerRoom(room.room_id, room.encryption) + await this.crypto.registerRoom(room.room_id) } // Mark for sync-gated content fetch: content will be loaded once the room diff --git a/test-e2e/content-after-join.test.mjs b/test-e2e/content-after-join.test.mjs index 653afcf..281b51e 100644 --- a/test-e2e/content-after-join.test.mjs +++ b/test-e2e/content-after-join.test.mjs @@ -192,7 +192,7 @@ describe('Content after Join', function () { console.log('Layer added to project') // Register encryption - await alice.crypto.setRoomEncryption(layer.globalId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await alice.crypto.setRoomEncryption(layer.globalId) // === Step 3: Initial sync for both (device discovery) === console.log('\n--- Step 3: Sync both sides ---') @@ -260,7 +260,7 @@ describe('Content after Join', function () { console.log('Bob synced and processed to_device events') // Register encryption for Bob - await bob.crypto.setRoomEncryption(layer.globalId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await bob.crypto.setRoomEncryption(layer.globalId) // === Step 7: Bob joins the layer and loads content === console.log('\n--- Step 7: Bob joins layer and loads content ---') diff --git a/test-e2e/e2ee.test.mjs b/test-e2e/e2ee.test.mjs index a8a3437..036aca8 100644 --- a/test-e2e/e2ee.test.mjs +++ b/test-e2e/e2ee.test.mjs @@ -181,8 +181,8 @@ describe('E2EE Integration (Tuwunel)', function () { }) // Register room encryption with both crypto managers - await aliceCrypto.setRoomEncryption(room.room_id, { algorithm: 'm.megolm.v1.aes-sha2' }) - await bobCrypto.setRoomEncryption(room.room_id, { algorithm: 'm.megolm.v1.aes-sha2' }) + await aliceCrypto.setRoomEncryption(room.room_id) + await bobCrypto.setRoomEncryption(room.room_id) // Sync both sides to pick up device lists await syncAndProcess(aliceCrypto, alice.accessToken) diff --git a/test-e2e/matrix-client-api.test.mjs b/test-e2e/matrix-client-api.test.mjs index 782e868..67ae1d7 100644 --- a/test-e2e/matrix-client-api.test.mjs +++ b/test-e2e/matrix-client-api.test.mjs @@ -248,8 +248,8 @@ describe('matrix-client-api E2EE Integration', function () { await bob.httpAPI.join(roomId) // Register encryption with both CryptoManagers - await alice.crypto.setRoomEncryption(roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) - await bob.crypto.setRoomEncryption(roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await alice.crypto.setRoomEncryption(roomId) + await bob.crypto.setRoomEncryption(roomId) // Initial sync for both to discover device lists const aSync = await alice.httpAPI.sync(undefined, undefined, 0) @@ -314,8 +314,8 @@ describe('matrix-client-api E2EE Integration', function () { await alice.httpAPI.invite(roomId, bobCreds.user_id) await bob.httpAPI.join(roomId) - await alice.crypto.setRoomEncryption(roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) - await bob.crypto.setRoomEncryption(roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await alice.crypto.setRoomEncryption(roomId) + await bob.crypto.setRoomEncryption(roomId) // Initial sync for both const aSync = await alice.httpAPI.sync(undefined, undefined, 0) @@ -382,8 +382,8 @@ describe('matrix-client-api E2EE Integration', function () { await bob.httpAPI.join(layer.globalId) // 3. Register encryption - await alice.crypto.setRoomEncryption(layer.globalId, { algorithm: 'm.megolm.v1.aes-sha2' }) - await bob.crypto.setRoomEncryption(layer.globalId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await alice.crypto.setRoomEncryption(layer.globalId) + await bob.crypto.setRoomEncryption(layer.globalId) // 4. Initial sync both const aSync = await alice.httpAPI.sync(undefined, undefined, 0) diff --git a/test-e2e/sync-gated-content.test.mjs b/test-e2e/sync-gated-content.test.mjs index 87882b4..2ae2b58 100644 --- a/test-e2e/sync-gated-content.test.mjs +++ b/test-e2e/sync-gated-content.test.mjs @@ -302,7 +302,7 @@ describe('Sync-Gated Content (E2E)', function () { ) layerRoomId = layer.globalId await alice.structureAPI.addLayerToProject(project.globalId, layerRoomId) - await aliceCrypto.setRoomEncryption(layerRoomId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await aliceCrypto.setRoomEncryption(layerRoomId) // Sync both sides for device discovery await doSync(alice.httpAPI, aliceCrypto, undefined, 0) @@ -335,7 +335,7 @@ describe('Sync-Gated Content (E2E)', function () { // Bob syncs to receive historical keys (before joining the layer) const bSync = await doSync(bob.httpAPI, bobCrypto, undefined, 0) bobSyncToken = bSync.next_batch - await bobCrypto.setRoomEncryption(layerRoomId, { algorithm: 'm.megolm.v1.aes-sha2' }) + await bobCrypto.setRoomEncryption(layerRoomId) }) it('sync-gated content() decrypts all events after join', async function () { diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs index 5baf88a..48d6a68 100644 --- a/test/crypto.test.mjs +++ b/test/crypto.test.mjs @@ -123,7 +123,7 @@ describe('Room Encryption Registration', function () { it('should register a room for encryption without error', async () => { const crypto = new CryptoManager() await crypto.initialize('@alice:test', 'DEVICE_A') - await crypto.setRoomEncryption('!room:test', { algorithm: 'm.megolm.v1.aes-sha2' }) + await crypto.setRoomEncryption('!room:test') // No error means success }) @@ -139,7 +139,7 @@ describe('Room Encryption Registration', function () { } } - await crypto.setRoomEncryption('!room:test', { algorithm: 'm.megolm.v1.aes-sha2' }) + await crypto.setRoomEncryption('!room:test') await crypto.updateTrackedUsers(['@alice:test']) // After room registration, shareRoomKey should be callable @@ -264,7 +264,7 @@ describe('Encrypt / Decrypt Round-Trip (self)', function () { } } - await alice.setRoomEncryption('!room:test', { algorithm: 'm.megolm.v1.aes-sha2' }) + await alice.setRoomEncryption('!room:test') await alice.updateTrackedUsers(['@alice:test']) // Query own keys diff --git a/test/sync-gated-content.test.mjs b/test/sync-gated-content.test.mjs index cc85e5d..728586f 100644 --- a/test/sync-gated-content.test.mjs +++ b/test/sync-gated-content.test.mjs @@ -96,7 +96,8 @@ describe('Sync-Gated Content after Join', function () { let registeredRoom = null const fakeCrypto = { - setRoomEncryption: async (rid) => { registeredRoom = rid } + isEnabled: true, + registerRoom: async (rid) => { registeredRoom = rid } } const project = new Project({ @@ -112,7 +113,7 @@ describe('Sync-Gated Content after Join', function () { }), timelineAPI: createTimelineAPI(), commandAPI: createCommandAPI(), - cryptoManager: fakeCrypto + crypto: fakeCrypto }) await project.joinLayer(roomId) @@ -126,7 +127,8 @@ describe('Sync-Gated Content after Join', function () { let registeredRoom = null const fakeCrypto = { - setRoomEncryption: async (rid) => { registeredRoom = rid } + isEnabled: true, + registerRoom: async (rid) => { registeredRoom = rid } } const project = new Project({ @@ -141,7 +143,7 @@ describe('Sync-Gated Content after Join', function () { }), timelineAPI: createTimelineAPI(), commandAPI: createCommandAPI(), - cryptoManager: fakeCrypto + crypto: fakeCrypto }) await project.joinLayer(roomId) From a15769904f40ff1271487c20c27eb101c18c106b Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Fri, 20 Mar 2026 09:56:22 +0100 Subject: [PATCH 18/19] added oxlint --- package-lock.json | 369 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 + 2 files changed, 372 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5dd9082..a4cdd6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "levelup": "^5.1.1", "memdown": "^6.1.1", "mocha": "^10.2.0", + "oxlint": "^1.56.0", "subleveldown": "^6.0.1" } }, @@ -40,6 +41,329 @@ "node": ">= 18" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", + "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", + "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", + "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", + "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", + "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", + "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", + "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", + "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", + "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", + "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", + "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", + "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", + "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", + "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", + "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", + "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", + "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", + "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", + "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/abstract-leveldown": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-7.2.0.tgz", @@ -929,6 +1253,51 @@ "wrappy": "1" } }, + "node_modules/oxlint": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", + "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.56.0", + "@oxlint/binding-android-arm64": "1.56.0", + "@oxlint/binding-darwin-arm64": "1.56.0", + "@oxlint/binding-darwin-x64": "1.56.0", + "@oxlint/binding-freebsd-x64": "1.56.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", + "@oxlint/binding-linux-arm-musleabihf": "1.56.0", + "@oxlint/binding-linux-arm64-gnu": "1.56.0", + "@oxlint/binding-linux-arm64-musl": "1.56.0", + "@oxlint/binding-linux-ppc64-gnu": "1.56.0", + "@oxlint/binding-linux-riscv64-gnu": "1.56.0", + "@oxlint/binding-linux-riscv64-musl": "1.56.0", + "@oxlint/binding-linux-s390x-gnu": "1.56.0", + "@oxlint/binding-linux-x64-gnu": "1.56.0", + "@oxlint/binding-linux-x64-musl": "1.56.0", + "@oxlint/binding-openharmony-arm64": "1.56.0", + "@oxlint/binding-win32-arm64-msvc": "1.56.0", + "@oxlint/binding-win32-ia32-msvc": "1.56.0", + "@oxlint/binding-win32-x64-msvc": "1.56.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.15.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index 7dc13f1..4e40d05 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "A minimal client API for [matrix]", "main": "index.mjs", "scripts": { + "lint": "oxlint", + "lint:fix": "oxlint --fix", "test": "mocha ./test/*", "test:e2e": "mocha --timeout 30000 ./test-e2e/*.test.mjs" }, @@ -24,6 +26,7 @@ "levelup": "^5.1.1", "memdown": "^6.1.1", "mocha": "^10.2.0", + "oxlint": "^1.56.0", "subleveldown": "^6.0.1" } } From 6a21f0a7525bdd05248be5a05c14132ece4c265e Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Fri, 20 Mar 2026 09:57:14 +0100 Subject: [PATCH 19/19] removed unused imports and variables --- src/convenience.mjs | 1 - src/http-api.mjs | 5 +++-- src/timeline-api.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/convenience.mjs b/src/convenience.mjs index bd9dab0..32ba1ac 100644 --- a/src/convenience.mjs +++ b/src/convenience.mjs @@ -1,4 +1,3 @@ -import { powerlevel } from "./powerlevel.mjs" import { getLogger } from './logger.mjs' const effectiveFilter = filter => { diff --git a/src/http-api.mjs b/src/http-api.mjs index f3770dc..ff6d77a 100644 --- a/src/http-api.mjs +++ b/src/http-api.mjs @@ -12,10 +12,11 @@ const RETRY_LIMIT = 2 * @readonly * @enum {string} */ -const Direction = { +/* const Direction = { backward: 'b', forward: 'f' -} +} */ +// dead function HttpAPI (credentials) { this.credentials = { diff --git a/src/timeline-api.mjs b/src/timeline-api.mjs index 06d0a47..f5e732c 100644 --- a/src/timeline-api.mjs +++ b/src/timeline-api.mjs @@ -64,7 +64,7 @@ TimelineAPI.prototype.credentials = function () { return this.httpApi.credentials } -TimelineAPI.prototype.content = async function (roomId, filter, from) { +TimelineAPI.prototype.content = async function (roomId, filter, _from) { getLogger().debug('Timeline content filter:', JSON.stringify(filter)) // Augment the filter for crypto: add m.room.encrypted to types