From 2cfc29b7b890f423af017e67fe1b2d83234ef203 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Sat, 11 Jul 2026 02:39:04 +0530 Subject: [PATCH] add missing validations --- src/services/owner-op-routes.ts | 34 +++-- src/services/session-manager.ts | 57 +++++++- src/services/socket-handlers.ts | 76 ++++++++-- src/tests/services/owner-op-routes.test.ts | 30 +++- src/tests/services/session-manager.test.ts | 31 +++- .../socket-handlers.handleAuth.test.ts | 136 ++++++++++++++++-- ...cket-handlers.handleDocumentUpdate.test.ts | 28 ++++ src/types/index.ts | 2 + src/utils/contract.ts | 7 +- 9 files changed, 361 insertions(+), 40 deletions(-) diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index f74843c..6192c19 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -8,7 +8,10 @@ import { Hex } from "viem"; export interface OwnerOpDeps { authService: Pick; - sessionManager: Pick; + sessionManager: Pick< + SessionManager, + "getSession" | "setCollabJoinEnabled" | "getNonTerminatedSessionsForDocument" + >; } export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) { @@ -34,17 +37,28 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) return; } - await deps.sessionManager.setCollabJoinEnabled(documentId, sessionDid, !!enabled); + // Apply to EVERY non-terminated session of the doc, not just the client-supplied + // sessionDid: a joiner derives the current session from its own roomKey and reads that + // session's flag on `/auth`, so a flag left on a stale session is a silent re-join hole. + // Safe doc-wide: this op only drops non-owner sockets and the flag is a reversible + // join-gate, so any proven owner may close the whole doc without harming a co-owner. + const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); + const sessionDids = new Set(targets.map((t) => t.sessionDid)); + sessionDids.add(sessionDid); + for (const sd of sessionDids) { + await deps.sessionManager.setCollabJoinEnabled(documentId, sd, !!enabled); + } if (enabled === false) { - // Force-drop live non-owner sockets in the room (stop-share teeth). `io` is the app's - // Socket.IO server; `fetchSockets()` reaches every socket in the room (across instances too - // when the Redis adapter is enabled), so `.disconnect(true)` drops it wherever it lives. The - // returned `.data` is a read-only snapshot — we disconnect rather than mutate it. - const roomName = getRoomName(documentId, sessionDid); - const sockets = await io.in(roomName).fetchSockets(); - for (const s of sockets) { - if (s.data.role !== "owner") s.disconnect(true); + // Force-drop live non-owner sockets across ALL of the doc's rooms (stop-share teeth). + // `fetchSockets()` reaches every socket in a room (across instances when the Redis + // adapter is on), so `.disconnect(true)` drops it wherever it lives. + for (const sd of sessionDids) { + const roomName = getRoomName(documentId, sd); + const sockets = await io.in(roomName).fetchSockets(); + for (const s of sockets) { + if (s.data.role !== "owner") s.disconnect(true); + } } } diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 1e8ac69..47adc80 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -77,6 +77,35 @@ export class SessionManager { return runtimeSession; } + // R3 heal: fill a session's ownerIdentityDid only when it is currently absent/empty. + // The conditional filter makes this atomic and immutable-preserving — it never + // overwrites a real binding, so concurrent callers race to a single first-proven fill. + async fillOwnerIdentityDidIfAbsent( + documentId: string, + sessionDid: string, + ownerIdentityDid: string + ): Promise { + try { + await SessionModel.updateOne( + { + documentId, + sessionDid, + $or: [ + { ownerIdentityDid: { $exists: false } }, + { ownerIdentityDid: null }, + { ownerIdentityDid: "" }, + ], + }, + { $set: { ownerIdentityDid } } + ); + const sessionKey = this.getSessionKey(documentId, sessionDid); + const mem = this.inMemorySessions.get(sessionKey); + if (mem && !mem.ownerIdentityDid) mem.ownerIdentityDid = ownerIdentityDid; + } catch (error) { + console.error("Error filling ownerIdentityDid:", error); + } + } + async getSession(documentId: string, sessionDid: string): Promise { // Check in-memory first for active sessions const sessionKey = this.getSessionKey(documentId, sessionDid); @@ -211,13 +240,17 @@ export class SessionManager { } } - async getOtherActiveSessions( + // All non-terminated sessions for this owner's document except the caller's own. The + // rotation path terminates these when a new session supersedes them; including INACTIVE + // (not only active) sessions closes a forward-secrecy hole where an idled-out old-roomKey + // session lingered `/auth`-able forever and was never GC'd. + async getOtherNonTerminatedSessions( documentId: string, ownerDid: string, excludeSessionDid?: string ): Promise> { try { - const query: Record = { documentId, ownerDid, state: "active" }; + const query: Record = { documentId, ownerDid, state: { $ne: "terminated" } }; if (excludeSessionDid) { query.sessionDid = { $ne: excludeSessionDid }; } @@ -228,7 +261,25 @@ export class SessionManager { appType: s.appType, })); } catch (error) { - console.error("Error getting other active sessions:", error); + console.error("Error getting other non-terminated sessions:", error); + return []; + } + } + + // Every non-terminated session for a document, regardless of owner. Stop-share uses this + // to close the collab-join flag and drop live non-owner sockets across ALL of a doc's + // rooms — a stale client-supplied sessionDid must not leave the current room open. + async getNonTerminatedSessionsForDocument( + documentId: string + ): Promise> { + try { + const sessions = await SessionModel.find( + { documentId, state: { $ne: "terminated" } }, + { sessionDid: 1 } + ).lean(); + return sessions.map((s: any) => ({ sessionDid: s.sessionDid })); + } catch (error) { + console.error("Error getting non-terminated sessions for document:", error); return []; } } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index a2fcb51..3ff7e4e 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -183,8 +183,34 @@ export async function handleAuth( }); } + // R3 owner binding (ddoc-only): the identity that becomes the room's root of + // trust must be cryptographically proven, not client-asserted. Verify the identity + // UCAN against the on-chain signingDid and bind THAT. Dsheets keep the legacy path + // (no durable recovery / owner-op surface) — mirrors the ddoc-only join gate so a + // dsheet owner is never rejected here. + let boundOwnerIdentityDid = args.ownerIdentityDid; + if (claimedAppType === "ddoc") { + const provenSigningDid = + args.identityToken && args.identityContractAddress + ? await authService.verifyIdentityToken( + args.identityToken, + args.identityContractAddress as Hex, + documentId + ) + : null; + if (!provenSigningDid) { + return callback({ + status: false, + statusCode: 401, + error: "Valid identity proof required to create a durable session", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + } + boundOwnerIdentityDid = provenSigningDid; + } + // Terminate other sessions with socket notification - const otherSessions = await sessionManager.getOtherActiveSessions( + const otherSessions = await sessionManager.getOtherNonTerminatedSessions( documentId, ownerDid, sessionDid @@ -224,7 +250,7 @@ export async function handleAuth( documentId, sessionDid, ownerDid, - ownerIdentityDid: args.ownerIdentityDid, + ownerIdentityDid: boundOwnerIdentityDid, portalAddress: args.contractAddress, collabJoinEnabled: false, roomInfo: args.roomInfo, @@ -285,6 +311,31 @@ export async function handleAuth( role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + // R3 heal (ddoc-only): a session bound before identity proof was required (or + // bound empty) is filled — once, atomically — by a proven owner, so the pre-fix + // corpus becomes recoverable on the owner's next open. Never overwrites a real bind. + if ( + storedAppType === "ddoc" && + role === "owner" && + !existingSession.ownerIdentityDid && + args.identityToken && + args.identityContractAddress + ) { + const provenSigningDid = await authService.verifyIdentityToken( + args.identityToken, + args.identityContractAddress as Hex, + documentId + ); + if (provenSigningDid) { + await sessionManager.fillOwnerIdentityDidIfAbsent( + documentId, + existingSession.sessionDid, + provenSigningDid + ); + existingSession.ownerIdentityDid = provenSigningDid; + } + } + // collabJoinEnabled gate (R5-b): ddoc-ONLY (dsheet must never break; new dsheet rooms also get // collabJoinEnabled:false from createSession, so an unscoped gate would kill all dsheet collaboration). // Read the flag FRESH FROM MONGO (HTTP process flips it in Task 13; in-memory copy can be stale). @@ -427,17 +478,8 @@ export async function handleDocumentUpdate( const updateId = uuidv4(); const createdAt = Date.now(); - // Broadcast to peers immediately, before awaiting DB write - const roomName = getRoomName(documentId, socket.data.sessionDid); - const contentPayload = { - id: updateId, - data, - createdAt, - roomId: documentId, - }; - socket.to(roomName).emit("/document/content_update", contentPayload); - - // Persist to DB — sender's ACK waits for this + // Persist before broadcasting: a peer must never hold an update that is absent from + // the durable seq stream. On write failure this fails safe — nothing is fanned out. const update = await mongodbStore.createUpdate({ id: updateId, documentId, @@ -450,6 +492,14 @@ export async function handleDocumentUpdate( appType: socket.data.appType, }); + const roomName = getRoomName(documentId, socket.data.sessionDid); + socket.to(roomName).emit("/document/content_update", { + id: updateId, + data, + createdAt, + roomId: documentId, + }); + callback({ status: true, statusCode: 200, diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 1f239d9..b3536c3 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -14,7 +14,11 @@ describe("POST collab-join-enabled", () => { vi.clearAllMocks(); deps = { authService: { verifyOwnerOp: vi.fn() }, - sessionManager: { getSession: vi.fn(), setCollabJoinEnabled: vi.fn() }, + sessionManager: { + getSession: vi.fn(), + setCollabJoinEnabled: vi.fn(), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([]), + }, }; io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; }); @@ -47,6 +51,30 @@ describe("POST collab-join-enabled", () => { expect(ownerSock.disconnect).not.toHaveBeenCalled(); expect(r.status).toHaveBeenCalledWith(200); }); + + it("closes the flag + drops sockets across ALL non-terminated sessions, not just the client's", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-stale", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([ + { sessionDid: "s-cur" }, + { sessionDid: "s-old" }, + ]); + const editorSock: any = { data: { role: "editor" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([editorSock]) })); + const r = res(); + + await createCollabJoinEnabledHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-stale", enabled: false } } as any, r + ); + + // Every session — the current one, the old one, and the caller's own stale one — is closed. + for (const sd of ["s-cur", "s-old", "s-stale"]) { + expect(deps.sessionManager.setCollabJoinEnabled).toHaveBeenCalledWith("doc-1", sd, false); + expect(io.in).toHaveBeenCalledWith(`session::doc-1__${sd}`); + } + expect(editorSock.disconnect).toHaveBeenCalledTimes(3); + expect(r.status).toHaveBeenCalledWith(200); + }); }); describe("POST /list-my-documents", () => { diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index b11026c..332b041 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -189,20 +189,24 @@ describe("SessionManager", () => { }); }); - // getOtherActiveSessions + // getOtherNonTerminatedSessions - it("returns other active sessions from the database excluding the given sessionDid", async () => { + it("returns other non-terminated sessions (incl. inactive) excluding the given sessionDid", async () => { vi.mocked(SessionModel.find).mockResolvedValue([ { documentId: "doc-1", sessionDid: "other-session-1", appType: "ddoc" }, { documentId: "doc-1", sessionDid: "other-session-2", appType: "dsheet" }, ] as any); - const sessions = await sessionManager.getOtherActiveSessions("doc-1", "owner-did", "session-1"); + const sessions = await sessionManager.getOtherNonTerminatedSessions( + "doc-1", + "owner-did", + "session-1" + ); expect(SessionModel.find).toHaveBeenCalledWith({ documentId: "doc-1", ownerDid: "owner-did", - state: "active", + state: { $ne: "terminated" }, sessionDid: { $ne: "session-1" }, }); expect(sessions).toEqual([ @@ -211,6 +215,25 @@ describe("SessionManager", () => { ]); }); + // getNonTerminatedSessionsForDocument + + it("returns every non-terminated sessionDid for a document regardless of owner", async () => { + vi.mocked(SessionModel.find).mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { sessionDid: "s-1" }, + { sessionDid: "s-2" }, + ]), + } as any); + + const sessions = await sessionManager.getNonTerminatedSessionsForDocument("doc-1"); + + expect(SessionModel.find).toHaveBeenCalledWith( + { documentId: "doc-1", state: { $ne: "terminated" } }, + { sessionDid: 1 } + ); + expect(sessions).toEqual([{ sessionDid: "s-1" }, { sessionDid: "s-2" }]); + }); + // getActiveSessionsCount it("returns the in-memory session count when sessions are present", async () => { diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index fdf39b8..ad2d439 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -46,16 +46,18 @@ describe("handleAuth", () => { const fakeAuthService = { verifyOwnerToken: vi.fn(), verifyCollaborationToken: vi.fn(), + verifyIdentityToken: vi.fn(), getServerDid: vi.fn(), }; const fakeSessionManager = { getSession: vi.fn(), - getOtherActiveSessions: vi.fn(), + getOtherNonTerminatedSessions: vi.fn(), terminateSession: vi.fn(), createSession: vi.fn(), updateRoomInfo: vi.fn(), addClientToSession: vi.fn(), getCollabJoinEnabled: vi.fn(), + fillOwnerIdentityDidIfAbsent: vi.fn(), }; const fakeMongoDBStore = {} as any; @@ -140,13 +142,16 @@ describe("handleAuth", () => { ownerToken: "owner-token", ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); fakeSessionManager.getSession.mockResolvedValue(undefined); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); - fakeSessionManager.getOtherActiveSessions.mockResolvedValue([]); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); fakeSessionManager.createSession.mockResolvedValue(undefined); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); @@ -161,7 +166,7 @@ describe("handleAuth", () => { fakeArgs.contractAddress, fakeArgs.ownerAddress ); - expect(fakeSessionManager.getOtherActiveSessions).toHaveBeenCalledWith( + expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", fakeArgs.sessionDid, @@ -170,7 +175,7 @@ describe("handleAuth", () => { documentId: fakeArgs.documentId, sessionDid: fakeArgs.sessionDid, ownerDid: "owner-did", - ownerIdentityDid: undefined, + ownerIdentityDid: "owner-identity-did", portalAddress: fakeArgs.contractAddress, collabJoinEnabled: false, roomInfo: fakeArgs.roomInfo, @@ -224,6 +229,8 @@ describe("handleAuth", () => { ownerToken: "owner-token", ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -246,7 +253,8 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(undefined); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); - fakeSessionManager.getOtherActiveSessions.mockResolvedValue(otherSessions); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue(otherSessions); fakeSessionManager.terminateSession.mockResolvedValue(undefined); fakeSessionManager.createSession.mockResolvedValue(undefined); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); @@ -263,7 +271,7 @@ describe("handleAuth", () => { fakeArgs.contractAddress, fakeArgs.ownerAddress ); - expect(fakeSessionManager.getOtherActiveSessions).toHaveBeenCalledWith( + expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", fakeArgs.sessionDid, @@ -373,7 +381,8 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(undefined); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); - fakeSessionManager.getOtherActiveSessions.mockResolvedValue([otherSession]); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([otherSession]); fakeSessionManager.terminateSession.mockResolvedValue(undefined); fakeSessionManager.createSession.mockResolvedValue(undefined); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); @@ -643,6 +652,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(existingSession); fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); fakeSessionManager.updateRoomInfo.mockResolvedValue(undefined); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); @@ -721,7 +731,8 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(undefined); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); - fakeSessionManager.getOtherActiveSessions.mockResolvedValue([]); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); fakeSessionManager.createSession.mockResolvedValue(undefined); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); @@ -926,5 +937,114 @@ describe("handleAuth", () => { expect(fakeSocket.join).toHaveBeenCalled(); }); + + it("rejects a new ddoc owner session when the identity proof is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + // no identityToken / identityContractAddress + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeAuthService.verifyIdentityToken).not.toHaveBeenCalled(); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Valid identity proof required to create a durable session", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("rejects a new ddoc owner session when the identity proof is invalid", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "bad-identity-token", + identityContractAddress: "0x0000000000000000000000000000000000000003", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue(null); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( + "bad-identity-token", + "0x0000000000000000000000000000000000000003", + "doc-1" + ); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Valid identity proof required to create a durable session", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("fills an absent ownerIdentityDid on an existing ddoc session for a proven owner", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + identityContractAddress: "0x0000000000000000000000000000000000000003", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + appType: "ddoc", + // no ownerIdentityDid (pre-fix / empty bind) + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("proven-identity-did"); + fakeSessionManager.fillOwnerIdentityDidIfAbsent.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( + "identity-token", + "0x0000000000000000000000000000000000000003", + "doc-1" + ); + expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).toHaveBeenCalledWith( + "doc-1", + "session-1", + "proven-identity-did" + ); + expect(fakeSocket.data.role).toBe("owner"); + }); }); diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index f961c26..5c7b12d 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -257,6 +257,34 @@ describe("handleDocumentUpdate", () => { ); }); + it("does NOT broadcast to peers when the durable persist fails (persist-before-broadcast)", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + fakeMongoDBStore.createUpdate.mockRejectedValue(new Error("db write failed")); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + // A peer must never hold an update absent from the durable seq stream. + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + }); + it("returns 500 when an unexpected error occurs in document update handler", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); diff --git a/src/types/index.ts b/src/types/index.ts index e83756b..515511b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,6 +111,8 @@ export interface AuthArgs { roomInfo?: string; appType?: AppType; ownerIdentityDid?: string; + identityToken?: string; + identityContractAddress?: string; } export interface AuthResponseData { diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 7781969..2c86b8e 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -4,8 +4,13 @@ import { PORTAL_CONTRACT_ABI } from "../abi/portal-contract-abi"; import { IDENTITY_MODULE_ABI } from "../abi/identity-module-abi"; import MemberCreds from "node-cache"; import { config } from "../config"; +// On-chain owner/identity DID cache. Kept short so a rotated or revoked signing key +// (or a removed collaborator) stops being trusted within minutes rather than a full +// day. Too short amplifies RPC reads — a rate-limited node returns null and would lock +// the real owner out — so this is a deliberate floor, env-overridable per deploy. +const IDENTITY_CACHE_TTL_SECONDS = Number(process.env.IDENTITY_CACHE_TTL_SECONDS) || 300; const cache = new MemberCreds({ - stdTTL: 60 * 60 * 24, // 24 hours + stdTTL: IDENTITY_CACHE_TTL_SECONDS, }); export const publicClient = createPublicClient({