From 4444f14d22e6b542afb17049e23e0198ce2ec258 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Wed, 5 Aug 2026 11:21:22 +0530 Subject: [PATCH] feat(dsheet): un-fence durable + edit-permission planes, keyed on owner-identity bind A session's bound ownerIdentityDid is the activation switch: unbound rooms (all existing dsheet rooms and everything legacy clients create) behave exactly as before, while bound rooms - only creatable by a client presenting a verified identityToken - get full ddoc parity. - create binds-if-presented for dsheet (ddoc still hard-requires; invalid proof is always 401), join role resolution and heal un-gated - portal pin runs for all dsheet creates (dsheet-typed creates previously skipped the anti-hijack pin entirely) - DocumentMeta gains insert-only appType, returned by /list-my-documents so recovery can route sheet vs document - createUpdate stamps appType from the session row instead of trusting callers (/flush previously mislabeled dsheet rows) - terminate no longer hard-deletes dsheet update/commit rows; orphan GC goes app-agnostic with a live-sibling-session guard and now sweeps commit rows - rail admission and the update/snapshot/awareness revocation rechecks key on socket.data.editPlaneEnforced (ddoc always, dsheet iff bound); a heal kicks pre-heal unenforced sockets - backfill-apptype.ts extended to stamp DocumentMeta (run post-deploy) --- src/database/models/document-meta.ts | 3 + src/index.ts | 6 +- src/scripts/backfill-apptype.ts | 5 +- src/services/flush-route.ts | 1 - src/services/mongodb-store.ts | 29 +- src/services/rotate-route.ts | 4 +- src/services/session-manager.ts | 13 +- src/services/socket-handlers.ts | 106 +++--- src/tests/services/flush-route.test.ts | 5 +- .../services/mongodb-store.pinPortal.test.ts | 7 + src/tests/services/mongodb-store.test.ts | 109 +++++- src/tests/services/owner-op-routes.test.ts | 5 +- src/tests/services/rotate-route.test.ts | 2 +- src/tests/services/session-manager.test.ts | 34 +- .../socket-handlers.handleAuth.test.ts | 340 +++++++++++++++++- .../socket-handlers.handleAwareness.test.ts | 20 ++ ...cket-handlers.handleDocumentUpdate.test.ts | 21 +- ...ket-handlers.handleSetDocumentMeta.test.ts | 2 +- .../socket-handlers.handleSnapshot.test.ts | 29 +- .../socket-handlers.terminateSession.test.ts | 17 +- src/types/index.ts | 3 + 21 files changed, 618 insertions(+), 143 deletions(-) diff --git a/src/database/models/document-meta.ts b/src/database/models/document-meta.ts index 7df2bce..c3cab6b 100644 --- a/src/database/models/document-meta.ts +++ b/src/database/models/document-meta.ts @@ -6,6 +6,7 @@ interface IDocumentMeta extends MongooseDocument { ownerDid: string | null; ownerIdentityDid: string | null; portalAddress: string | null; + appType?: "ddoc" | "dsheet"; editLock: string | null; // roomKey-wrapped title: string | null; // roomKey-encrypted updatedAt: number; @@ -21,6 +22,8 @@ const DocumentMetaSchema = new Schema({ ownerDid: { type: String, default: null }, ownerIdentityDid: { type: String, default: null }, portalAddress: { type: String, default: null }, + // Which Fileverse app owns this document. Absent ⇒ "ddoc" (legacy). + appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" }, editLock: { type: String, default: null }, title: { type: String, default: null }, updatedAt: { type: Number, required: true }, diff --git a/src/index.ts b/src/index.ts index 4856c52..e42f4d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,7 +119,7 @@ class CollaborationServer { "/documents/:documentId/rotate-session", createRotateSessionHandler({ authService, sessionManager, mongodbStore, rotationCoordinator, - terminateOldSession: async (documentId, sessionDid, appType) => { + terminateOldSession: async (documentId, sessionDid) => { const room = getRoomName(documentId, sessionDid); const sockets = await this.io!.in(room).fetchSockets(); // Laggard sockets stay AUTHED: their next write must reach createUpdate and get the @@ -128,7 +128,7 @@ class CollaborationServer { // See docs/architecture/gp-semaphore.md. for (const s of sockets) { s.leave(room); } await sessionManager.deactivateSession(documentId, sessionDid); - await sessionManager.terminateSession(documentId, sessionDid, appType); + await sessionManager.terminateSession(documentId, sessionDid); }, }, this.io) ); @@ -150,7 +150,7 @@ class CollaborationServer { const room = getRoomName(documentId, s.sessionDid); this.io!.to(room).emit("/session/terminated", { roomId: documentId }); for (const sock of await this.io!.in(room).fetchSockets()) sock.leave(room); - await sessionManager.terminateSession(documentId, s.sessionDid, s.appType ?? "ddoc"); + await sessionManager.terminateSession(documentId, s.sessionDid); } }, }) diff --git a/src/scripts/backfill-apptype.ts b/src/scripts/backfill-apptype.ts index da640e6..5aa06fd 100644 --- a/src/scripts/backfill-apptype.ts +++ b/src/scripts/backfill-apptype.ts @@ -16,6 +16,7 @@ import { DocumentUpdateModel, DocumentCommitModel, SessionModel, + DocumentMetaModel, } from "../database/models"; async function backfill(): Promise { @@ -24,16 +25,18 @@ async function backfill(): Promise { const filter = { appType: { $exists: false } }; const set = { $set: { appType: "ddoc" as const } }; - const [updates, commits, sessions] = await Promise.all([ + const [updates, commits, sessions, metas] = await Promise.all([ DocumentUpdateModel.updateMany(filter, set), DocumentCommitModel.updateMany(filter, set), SessionModel.updateMany(filter, set), + DocumentMetaModel.updateMany(filter, set), ]); console.log("appType backfill complete:"); console.log(` DocumentUpdate: ${updates.modifiedCount} tagged "ddoc"`); console.log(` DocumentCommit: ${commits.modifiedCount} tagged "ddoc"`); console.log(` Session: ${sessions.modifiedCount} tagged "ddoc"`); + console.log(` DocumentMeta: ${metas.modifiedCount} tagged "ddoc"`); await databaseService.disconnect(); } diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index 7d7ca38..7468c38 100644 --- a/src/services/flush-route.ts +++ b/src/services/flush-route.ts @@ -54,7 +54,6 @@ export function createFlushHandler(deps: FlushDeps) { commitCid: null, createdAt: Date.now(), sessionDid, - appType: "ddoc", }); } catch (err) { if (err instanceof SessionTerminatedError) { diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index 7da6dc8..6b9f918 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,4 +1,4 @@ -import { DocumentUpdate, DocumentCommit } from "../types/index"; +import { DocumentUpdate, DocumentCommit, AppType } from "../types/index"; import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel, DocumentEditEpochModel } from "../database/models"; export class SessionTerminatedError extends Error { @@ -37,7 +37,9 @@ export class MongoDBStore { commitCid: update.commitCid, createdAt: update.createdAt, sessionDid: update.sessionDid, - appType: update.appType, + // Server-authoritative: the session row decides which app owns this stream — + // an HTTP caller (e.g. /flush) has no trustworthy claim of its own. + appType: session.appType ?? "ddoc", }); await mongoUpdate.save(); @@ -257,6 +259,7 @@ export class MongoDBStore { ownerDid: string | null; ownerIdentityDid: string | null; portalAddress: string | null; + appType: AppType; editLock: string | null; title: string | null; }): Promise { @@ -267,6 +270,7 @@ export class MongoDBStore { ownerDid: meta.ownerDid, ownerIdentityDid: meta.ownerIdentityDid, portalAddress: meta.portalAddress, + appType: meta.appType, }, $set: { sessionDid: meta.sessionDid, @@ -292,6 +296,7 @@ export class MongoDBStore { ownerDid: string | null; ownerIdentityDid: string | null; sessionDid: string; + appType: AppType; }): Promise<{ portalAddress: string | null }> { // 1. Ensure the row exists; pin all binding fields on INSERT only. await DocumentMetaModel.findOneAndUpdate( @@ -304,6 +309,7 @@ export class MongoDBStore { sessionDid: p.sessionDid, updatedAt: Date.now(), isPublished: false, + appType: p.appType, }, }, { upsert: true, writeConcern: { w: "majority", j: true } } @@ -363,7 +369,7 @@ export class MongoDBStore { // Discovery: docs bound to the proven owner (identity DID or portal owner DID) — recovery for a wiped device. async listDocumentsForOwner( by: { ownerIdentityDid?: string; ownerDid?: string } - ): Promise> { + ): Promise> { const filter: Record = {}; if (by.ownerIdentityDid) filter.ownerIdentityDid = by.ownerIdentityDid; else if (by.ownerDid) filter.ownerDid = by.ownerDid; @@ -372,11 +378,12 @@ export class MongoDBStore { // unpublished durable docs (the publish reconciler flips this flag). filter.isPublished = { $ne: true }; - const metas: any[] = await DocumentMetaModel.find(filter).select("editLock title").lean(); + const metas: any[] = await DocumentMetaModel.find(filter).select("editLock title appType").lean(); return metas.map((m) => ({ documentId: m._id, editLock: m.editLock ?? null, title: m.title ?? null, + appType: (m.appType as AppType) ?? "ddoc", })); } @@ -599,12 +606,14 @@ export class MongoDBStore { return ids; } - // Conservative orphan GC: terminated ddoc streams with no editLock and no snapshot row. - // A real draft always has an editLock or a snapshot, so it is never swept. + // Conservative orphan GC: terminated update streams (both apps) with no live sibling + // session, no editLock, and no snapshot row. The purge is documentId-scoped, so a live + // sibling must be ruled out first — a real draft always has an editLock or a snapshot, + // so it is never swept; a terminated legacy dsheet stream's durability is its IPFS commit. async collectOrphans(graceMs: number): Promise { const cutoff = new Date(Date.now() - graceMs); const terminated: any[] = await SessionModel.find({ - appType: "ddoc", state: "terminated", createdAt: { $lt: cutoff }, + state: "terminated", createdAt: { $lt: cutoff }, }).lean(); let purged = 0; @@ -612,6 +621,11 @@ export class MongoDBStore { // Pre-durable sessions (insert-only portalAddress key absent) predate the // editLock/snapshot invariant this sweep relies on — never sweep them. if (!Object.prototype.hasOwnProperty.call(s, "portalAddress")) continue; + // A live sibling session owns this document's stream — never purge under it. + const live = await SessionModel.findOne({ documentId: s.documentId, state: { $ne: "terminated" } }) + .select("_id") + .lean(); + if (live) continue; const meta: any = await DocumentMetaModel.findById(s.documentId).lean(); if (meta?.editLock) continue; // real draft — never sweep const snap: any = await DocumentUpdateModel.findOne({ documentId: s.documentId, updateType: "snapshot" }).sort({ seq: -1 }).lean(); @@ -619,6 +633,7 @@ export class MongoDBStore { const legacy: any = await DocumentUpdateModel.findOne({ documentId: s.documentId, seq: { $exists: false } }).select("_id").lean(); if (legacy) continue; // pre-durable rows awaiting seq backfill — never sweep await DocumentUpdateModel.deleteMany({ documentId: s.documentId }); + await DocumentCommitModel.deleteMany({ documentId: s.documentId }); await SessionModel.deleteMany({ documentId: s.documentId }); await CounterModel.deleteOne({ _id: s.documentId }); purged++; diff --git a/src/services/rotate-route.ts b/src/services/rotate-route.ts index 5c73cb0..9aecdf0 100644 --- a/src/services/rotate-route.ts +++ b/src/services/rotate-route.ts @@ -14,7 +14,7 @@ export interface RotateDeps { sessionManager: Pick; mongodbStore: Pick; rotationCoordinator: Pick; - terminateOldSession: (documentId: string, sessionDid: string, appType: "ddoc" | "dsheet") => Promise; + terminateOldSession: (documentId: string, sessionDid: string) => Promise; } export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { @@ -95,7 +95,7 @@ export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { onCutover: () => { io.to(oldRoom).emit("/session/cutover", { roomId: documentId, epoch: gateEpoch }); setTimeout(() => { - void deps.terminateOldSession(documentId, oldSessionDid, oldSession.appType ?? "ddoc") + void deps.terminateOldSession(documentId, oldSessionDid) .catch((e) => console.error("rotate terminate error:", e)); }, T_DRAIN_MS); }, diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 48b7208..2853b18 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -1,4 +1,4 @@ -import { SessionModel, DocumentUpdateModel, DocumentCommitModel } from "../database/models"; +import { SessionModel } from "../database/models"; interface RuntimeSession { documentId: string; @@ -302,11 +302,7 @@ export class SessionManager { } } - async terminateSession( - documentId: string, - sessionDid: string, - appType: "ddoc" | "dsheet" = "ddoc" - ): Promise { + async terminateSession(documentId: string, sessionDid: string): Promise { const sessionKey = this.getSessionKey(documentId, sessionDid); this.inMemorySessions.delete(sessionKey); @@ -315,11 +311,6 @@ export class SessionManager { { documentId, sessionDid }, { state: "terminated", roomInfo: null } ); - - if (appType === "dsheet") { - await DocumentUpdateModel.deleteMany({ documentId, sessionDid }); - await DocumentCommitModel.deleteMany({ documentId, sessionDid }); - } } catch (error) { console.error("Error terminating session in database:", error); } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 8b20b3a..10e006c 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -163,6 +163,7 @@ export async function handleAuth( let actorHandle: string | undefined = undefined; let admittedEditEpoch: number | undefined = undefined; let provenIdentityDid: string | null = null; + let editPlaneEnforced: boolean; // joinOnly is strictly privilege-reducing: never create or bind a room on behalf of // a joiner. A workspace member's valid SHARED ownerToken would otherwise create the @@ -219,16 +220,15 @@ 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. + // R3 owner binding: the identity that becomes the room's root of trust must be + // cryptographically proven, not client-asserted. ddoc hard-requires the proof. + // dsheet binds-if-presented: a legacy dsheets.new client never sends an + // identityToken and keeps creating unbound rooms (legacy semantics — no owner-op + // surface, no edit-plane enforcement); a presented-but-invalid proof is rejected, + // never downgraded to an unbound create. let boundOwnerIdentityDid: string | null = null; - if (claimedAppType === "ddoc") { - const provenSigningDid = args.identityToken - ? await authService.verifyIdentityToken(args.identityToken, documentId) - : null; + if (args.identityToken) { + const provenSigningDid = await authService.verifyIdentityToken(args.identityToken, documentId); if (!provenSigningDid) { return callback({ status: false, @@ -239,6 +239,13 @@ export async function handleAuth( } boundOwnerIdentityDid = provenSigningDid; provenIdentityDid = provenSigningDid; + } else if (claimedAppType === "ddoc") { + return callback({ + status: false, + statusCode: 401, + error: "Valid identity proof required to create a durable session", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); } // C1: pin documentId → portalAddress on the first owner bind, immutably (trust-on-first-use). @@ -252,26 +259,25 @@ export async function handleAuth( // (pin-at-document-creation / an owner-authenticated reclaim path) is out of scope. The // doc→portal *deletion* proof lives in the DeletedFile webhook (onlyCollaborator-gated). // See docs/architecture/edit-permission.md. - if (claimedAppType === "ddoc") { - const { portalAddress: pinnedPortal } = await deps.mongodbStore.pinDocumentPortalIfAbsent({ - documentId, - portalAddress: args.contractAddress as string, - ownerDid, - ownerIdentityDid: boundOwnerIdentityDid, - sessionDid, - }); + const { portalAddress: pinnedPortal } = await deps.mongodbStore.pinDocumentPortalIfAbsent({ + documentId, + portalAddress: args.contractAddress as string, + ownerDid, + ownerIdentityDid: boundOwnerIdentityDid, + sessionDid, + appType: claimedAppType, + }); - if ( - !pinnedPortal || - pinnedPortal.toLowerCase() !== (args.contractAddress as string).toLowerCase() - ) { - return callback({ - status: false, - statusCode: 403, - error: "Document is owned by a different portal", - errorCode: ErrorCode.AUTH_TOKEN_INVALID, - }); - } + if ( + !pinnedPortal || + pinnedPortal.toLowerCase() !== (args.contractAddress as string).toLowerCase() + ) { + return callback({ + status: false, + statusCode: 403, + error: "Document is owned by a different portal", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); } // Terminate other sessions with socket notification @@ -301,12 +307,7 @@ export async function handleAuth( s.leave(oldRoomName); } - // Now clean up DB — use the terminated session's own appType, not the new connection's claimed one - await sessionManager.terminateSession( - oldSession.documentId, - oldSession.sessionDid, - oldSession.appType ?? "ddoc" - ); + await sessionManager.terminateSession(oldSession.documentId, oldSession.sessionDid); console.log( `[Auth] Terminated old session: ${oldSession.sessionDid} for document: ${documentId}` ); @@ -327,6 +328,7 @@ export async function handleAuth( sessionType = "new"; roomInfo = args.roomInfo; resolvedAppType = claimedAppType; + editPlaneEnforced = claimedAppType === "ddoc" || boundOwnerIdentityDid != null; } else if (existingSession) { // Join an existing session const userDid = await authService.verifyCollaborationToken( @@ -398,14 +400,14 @@ export async function handleAuth( existingSession.ownerDid = ownerDid; } - // Identity-based role (ddoc-only): the shared team-portal + // Identity-based role: the shared team-portal // DID cannot tell a member from the creator, but the per-person identity signingDid // can. On a bound session a presented identityToken DECIDES the role — owner needs // BOTH the proven identity match and the portal ownerToken match. An invalid token // is a 401; a token-less join on a bound session is never owner (capped to editor) — // a bound session already proved identity once, so a modern client always presents it. const boundIdentityDid = existingSession.ownerIdentityDid ?? null; - if (storedAppType === "ddoc" && boundIdentityDid && args.identityToken) { + if (boundIdentityDid && args.identityToken) { const provenDid = await authService.verifyIdentityToken(args.identityToken, documentId); if (!provenDid) { return callback({ @@ -421,7 +423,6 @@ export async function handleAuth( : "editor"; provenIdentityDid = provenDid; } else if ( - storedAppType === "ddoc" && boundIdentityDid && !args.identityToken ) { @@ -439,11 +440,10 @@ export async function handleAuth( // resolves a team member to owner — a join-only bearer never gets owner powers. if (args.joinOnly === true && role === "owner") role = "editor"; - // R3 heal (ddoc-only): a session bound before identity proof was required (or + // R3 heal: 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" && args.joinOnly !== true && !existingSession.ownerIdentityDid && @@ -458,10 +458,23 @@ export async function handleAuth( provenSigningDid ); existingSession.ownerIdentityDid = provenSigningDid; + + // Sockets admitted while the room was unbound carry no rail and no + // per-op recheck; drop them so they re-enter through the admission gate. + const healedRoom = getRoomName(documentId, existingSession.sessionDid); + for (const st of await io.in(healedRoom).fetchSockets()) { + if (st.id !== socket.id && st.data.editPlaneEnforced !== true) st.disconnect(true); + } } } - // Rail-exclusive edit admission (ddoc-only, non-owner): resolved by the credential the + // Edit-plane activation: ddoc always; dsheet iff the room has a bound owner + // identity (only a modern client can bind — legacy dsheet rooms keep today's + // valid-roomKey-writes semantics). Computed after the heal so a healing owner's + // socket is stamped enforced. + editPlaneEnforced = storedAppType === "ddoc" || existingSession.ownerIdentityDid != null; + + // Rail-exclusive edit admission (enforced rooms, non-owner): resolved by the credential the // client presents, never a sequential try-each. An editUcan join is GP-or-reject and must // NEVER fall through to workspace/public — a demoted GP editor still holds the un-rotated // roomKey, and falling through would let them re-enter as a lower-trust bearer. @@ -471,7 +484,7 @@ export async function handleAuth( // rail, but an owner-shaped bearer already cleared a stricter bar. rail/railKind are // left unset, so isStillAdmitted's default (checks collabJoinEnabled) fails closed for // any later write attempt on a private doc — the headless read path never calls it. - if (storedAppType === "ddoc" && role === "editor" && !(args.joinOnly === true && ownerVerifiedPreCap)) { + if (editPlaneEnforced && role === "editor" && !(args.joinOnly === true && ownerVerifiedPreCap)) { if (args.editUcan) { // Offline gp-actor admission: valid editUcan + epoch at/above the minEditEpoch floor. // No gate poll — rotation bumps the floor and terminates the old session, confining a @@ -556,6 +569,7 @@ export async function handleAuth( socket.data.actorHandle = actorHandle; socket.data.actorIdentityDid = provenIdentityDid ?? undefined; socket.data.editEpoch = admittedEditEpoch; + socket.data.editPlaneEnforced = editPlaneEnforced; // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); @@ -713,7 +727,7 @@ export async function handleDocumentUpdate( }); } - if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + if (socket.data.role !== "owner" && socket.data.editPlaneEnforced === true) { const revoked = !(await isStillAdmitted(socket, deps)); if (revoked) { // Ack the reason first, then drop the socket — the revoked actor must not @@ -745,7 +759,6 @@ export async function handleDocumentUpdate( commitCid: null, createdAt, sessionDid, - appType: socket.data.appType, }); } catch (err) { if (err instanceof SessionTerminatedError) { @@ -1005,7 +1018,7 @@ export async function handleSnapshot( // can already rewrite the full content through ordinary updates, so authorship grants // no new power — and it is what keeps an owner-absent document's log bounded. Same // per-op revocation check as the update path. - if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + if (socket.data.role !== "owner" && socket.data.editPlaneEnforced === true) { const revoked = !(await isStillAdmitted(socket, deps)); if (revoked) { return callback({ @@ -1209,6 +1222,7 @@ export async function handleSetDocumentMeta( ownerDid: session.ownerDid ?? null, ownerIdentityDid: session.ownerIdentityDid ?? null, portalAddress: session.portalAddress ?? null, + appType: socket.data.appType, editLock: args.editLock, title: args.title, }); @@ -1294,7 +1308,7 @@ export async function handleAwareness( // Post-broadcast, fire-and-forget: catches revoked-but-idle (reading, cursor-moving) // actors that never hit the write chokepoints. Kicks are per-actor/rail-off only. - if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + if (socket.data.role !== "owner" && socket.data.editPlaneEnforced === true) { const now = Date.now(); if (now - (socket.data.lastAdmitRecheckAt ?? 0) >= ADMIT_RECHECK_INTERVAL_MS) { socket.data.lastAdmitRecheckAt = now; @@ -1397,7 +1411,7 @@ export async function handleTerminateSession( await sessionManager.deactivateSession(documentId, session.sessionDid); // 5. Clean up DB - await sessionManager.terminateSession(documentId, session.sessionDid, session.appType ?? "ddoc"); + await sessionManager.terminateSession(documentId, session.sessionDid); callback({ status: true, diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts index 514ece2..94518a8 100644 --- a/src/tests/services/flush-route.test.ts +++ b/src/tests/services/flush-route.test.ts @@ -46,8 +46,11 @@ describe("POST /flush", () => { await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct" } } as any, r); expect(deps.authService.verifyCollaborationToken).toHaveBeenCalledWith("t", "s", "d"); expect(deps.mongodbStore.createUpdate).toHaveBeenCalledWith(expect.objectContaining({ - documentId: "d", data: "ct", updateType: "yjs_update", sessionDid: "s", appType: "ddoc", + documentId: "d", data: "ct", updateType: "yjs_update", sessionDid: "s", })); + expect(deps.mongodbStore.createUpdate).toHaveBeenCalledWith( + expect.not.objectContaining({ appType: expect.anything() }) + ); expect(r.status).toHaveBeenCalledWith(200); }); diff --git a/src/tests/services/mongodb-store.pinPortal.test.ts b/src/tests/services/mongodb-store.pinPortal.test.ts index 9614ee5..927d592 100644 --- a/src/tests/services/mongodb-store.pinPortal.test.ts +++ b/src/tests/services/mongodb-store.pinPortal.test.ts @@ -37,6 +37,7 @@ describe("pinDocumentPortalIfAbsent", () => { ownerDid: "did:key:o", ownerIdentityDid: "did:key:i", sessionDid: "did:key:s1", + appType: "ddoc", }); expect(findOneAndUpdate).toHaveBeenCalledWith( @@ -62,6 +63,7 @@ describe("pinDocumentPortalIfAbsent", () => { ownerDid: "did:key:o", ownerIdentityDid: "did:key:i", sessionDid: "did:key:s1", + appType: "ddoc", }); expect(updateOne).toHaveBeenCalledWith( @@ -79,6 +81,7 @@ describe("pinDocumentPortalIfAbsent", () => { ownerDid: "did:key:o2", ownerIdentityDid: "did:key:i2", sessionDid: "did:key:s2", + appType: "ddoc", }); // The caller's own portal is irrelevant to the return value — the effective @@ -98,6 +101,7 @@ describe("pinDocumentPortalIfAbsent", () => { ownerDid: null, ownerIdentityDid: "did:key:i", sessionDid: "did:key:s", + appType: "ddoc", }); expect(result.portalAddress).toBe("0xP1"); @@ -113,6 +117,7 @@ describe("pinDocumentPortalIfAbsent", () => { ownerDid: null, ownerIdentityDid: null, sessionDid: "did:key:s", + appType: "ddoc", }); expect(result).toEqual({ portalAddress: null }); @@ -133,6 +138,7 @@ describe("upsertDocumentMeta binding immutability", () => { ownerDid: "did:key:x", ownerIdentityDid: "did:key:x", portalAddress: "0xEVIL", + appType: "ddoc", editLock: "lk", title: "t", }); @@ -144,6 +150,7 @@ describe("upsertDocumentMeta binding immutability", () => { ownerDid: "did:key:x", ownerIdentityDid: "did:key:x", portalAddress: "0xEVIL", + appType: "ddoc", }, $set: expect.objectContaining({ sessionDid: "did:key:s", diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 73c1ecb..fd67a12 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -107,6 +107,23 @@ describe("createUpdate: durable-write gate", () => { expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); }); + + it("stamps appType from the session row, not the caller", async () => { + const { CounterModel, SessionModel, DocumentUpdateModel: MockedUpdateModel } = + await import("../../database/models"); + (SessionModel.findOne as any).mockResolvedValue({ state: "active", appType: "dsheet" }); + (CounterModel.findOneAndUpdate as any).mockResolvedValue({ _id: "doc-1", seq: 1 }); + + const store = new MongoDBStore(); + await store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", + }); + + expect(MockedUpdateModel).toHaveBeenCalledWith( + expect.objectContaining({ appType: "dsheet" }) + ); + }); }); describe("schema: seq ordering", () => { @@ -207,7 +224,7 @@ describe("upsertDocumentMeta", () => { const store = new MongoDBStore(); await store.upsertDocumentMeta({ documentId: "doc-1", sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", - portalAddress: "0xP", editLock: "el", title: "t", + portalAddress: "0xP", appType: "ddoc", editLock: "el", title: "t", }); expect(DocumentMetaModel.findOneAndUpdate).toHaveBeenCalledWith( @@ -215,7 +232,7 @@ describe("upsertDocumentMeta", () => { expect.objectContaining({ // Binding fields are first-writer-immutable (C1) — pinned on insert only. $setOnInsert: expect.objectContaining({ - ownerDid: "od", ownerIdentityDid: "oid", portalAddress: "0xP", + ownerDid: "od", ownerIdentityDid: "oid", portalAddress: "0xP", appType: "ddoc", }), $set: expect.objectContaining({ sessionDid: "room-did", editLock: "el", title: "t", @@ -250,8 +267,8 @@ describe("listDocumentsForOwner", () => { isPublished: { $ne: true }, }); expect(result).toEqual([ - { documentId: "doc-1", editLock: "el-1", title: "t1" }, - { documentId: "doc-2", editLock: "el-2", title: "t2" }, + { documentId: "doc-1", editLock: "el-1", title: "t1", appType: "ddoc" }, + { documentId: "doc-2", editLock: "el-2", title: "t2", appType: "ddoc" }, ]); }); @@ -309,6 +326,29 @@ describe("listDocumentsForOwner", () => { }); }); +describe("listDocumentsForOwner: appType routing", () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it("returns each row's appType, defaulting absent (legacy) rows to ddoc", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + const lean = vi.fn().mockResolvedValue([ + { _id: "doc-1", editLock: "lock", title: "t", appType: "dsheet" }, + { _id: "doc-2", editLock: null, title: null }, + ]); + const select = vi.fn(() => ({ lean })); + (DocumentMetaModel.find as any).mockReturnValue({ select }); + + const store = new MongoDBStore(); + const docs = await store.listDocumentsForOwner({ ownerIdentityDid: "did:key:x" }); + + expect(select).toHaveBeenCalledWith("editLock title appType"); + expect(docs).toEqual([ + { documentId: "doc-1", editLock: "lock", title: "t", appType: "dsheet" }, + { documentId: "doc-2", editLock: null, title: null, appType: "ddoc" }, + ]); + }); +}); + describe("getHydrationRange", () => { beforeEach(() => { vi.clearAllMocks(); @@ -462,45 +502,92 @@ describe("collectOrphans", () => { vi.clearAllMocks(); }); + // No live sibling session on the documentId — the sweep's default posture in every + // case below except the one that specifically tests the live-sibling guard. + function mockNoLiveSibling(SessionModel: any) { + (SessionModel.findOne as any).mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); + } + it("purges a terminated ddoc stream with no editLock and no snapshot", async () => { - const { SessionModel, DocumentMetaModel, DocumentUpdateModel } = await import("../../database/models"); - (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "orphan", sessionDid: "s" }]) }); + const { SessionModel, DocumentMetaModel, DocumentUpdateModel, DocumentCommitModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "orphan", sessionDid: "s", portalAddress: null }]) }); + mockNoLiveSibling(SessionModel); (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }); // no editLock - (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); // no snapshot + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }), // no snapshot + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }), // no pre-durable legacy row + }); const store = new MongoDBStore(); const n = await store.collectOrphans(60_000); expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ documentId: "orphan" }); + expect(DocumentCommitModel.deleteMany).toHaveBeenCalledWith({ documentId: "orphan" }); expect(n).toBe(1); }); + it("spares a document that still has a non-terminated sibling session", async () => { + const { SessionModel, DocumentMetaModel, DocumentUpdateModel, DocumentCommitModel, CounterModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "shared-doc", sessionDid: "s", portalAddress: null }]) }); + (SessionModel.findOne as any).mockReturnValue({ select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "live-session" }) }) }); + + const store = new MongoDBStore(); + const n = await store.collectOrphans(60_000); + + expect(SessionModel.findOne).toHaveBeenCalledWith({ documentId: "shared-doc", state: { $ne: "terminated" } }); + // The live-sibling guard short-circuits before any of the other guards run. + expect(DocumentMetaModel.findById).not.toHaveBeenCalled(); + expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(DocumentCommitModel.deleteMany).not.toHaveBeenCalled(); + expect(SessionModel.deleteMany).not.toHaveBeenCalled(); + expect(CounterModel.deleteOne).not.toHaveBeenCalled(); + expect(n).toBe(0); + }); + it("keeps a terminated stream that still has an editLock (real draft)", async () => { - const { SessionModel, DocumentMetaModel, DocumentUpdateModel } = await import("../../database/models"); - (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "draft", sessionDid: "s" }]) }); + const { SessionModel, DocumentMetaModel, DocumentUpdateModel, DocumentCommitModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "draft", sessionDid: "s", portalAddress: null }]) }); + mockNoLiveSibling(SessionModel); (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ editLock: "el" }) }); (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); const store = new MongoDBStore(); const n = await store.collectOrphans(60_000); + expect(DocumentMetaModel.findById).toHaveBeenCalledWith("draft"); // guard genuinely consulted the editLock expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(DocumentCommitModel.deleteMany).not.toHaveBeenCalled(); expect(n).toBe(0); }); it("keeps a terminated stream that has no editLock but still has a snapshot (hydration base)", async () => { - const { SessionModel, DocumentMetaModel, DocumentUpdateModel, CounterModel } = await import("../../database/models"); - (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "snapdraft", sessionDid: "s" }]) }); + const { SessionModel, DocumentMetaModel, DocumentUpdateModel, DocumentCommitModel, CounterModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "snapdraft", sessionDid: "s", portalAddress: null }]) }); + mockNoLiveSibling(SessionModel); (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }); // no editLock (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "snap1", documentId: "snapdraft", seq: 5 }) }) }); // snapshot exists const store = new MongoDBStore(); const n = await store.collectOrphans(60_000); + expect(DocumentUpdateModel.findOne).toHaveBeenCalled(); // guard genuinely consulted the snapshot query expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(DocumentCommitModel.deleteMany).not.toHaveBeenCalled(); expect(SessionModel.deleteMany).not.toHaveBeenCalled(); expect(CounterModel.deleteOne).not.toHaveBeenCalled(); expect(n).toBe(0); }); + + it("queries terminated sessions across BOTH app types (no appType filter)", async () => { + const { SessionModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }); + + const store = new MongoDBStore(); + await store.collectOrphans(1000); + + expect(SessionModel.find).toHaveBeenCalledWith({ + state: "terminated", createdAt: { $lt: expect.any(Date) }, + }); + }); }); describe("getShareContext", () => { diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 9489010..3a17f57 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -191,7 +191,7 @@ describe("POST /list-my-documents", () => { vi.clearAllMocks(); deps = { authService: { verifyIdentityToken: vi.fn(), verifyOwnerToken: vi.fn() }, - mongodbStore: { listDocumentsForOwner: vi.fn().mockResolvedValue([{ documentId: "d1", editLock: "el", title: "t" }]) }, + mongodbStore: { listDocumentsForOwner: vi.fn().mockResolvedValue([{ documentId: "d1", editLock: "el", title: "t", appType: "dsheet" }]) }, }; }); @@ -203,6 +203,9 @@ describe("POST /list-my-documents", () => { ); expect(deps.mongodbStore.listDocumentsForOwner).toHaveBeenCalledWith({ ownerIdentityDid: "did:key:zOwner" }); expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ + documents: [{ documentId: "d1", editLock: "el", title: "t", appType: "dsheet" }], + }); }); it("401s when the fields are missing (short-circuits before verifyIdentityToken)", async () => { diff --git a/src/tests/services/rotate-route.test.ts b/src/tests/services/rotate-route.test.ts index bf7c851..f20779c 100644 --- a/src/tests/services/rotate-route.test.ts +++ b/src/tests/services/rotate-route.test.ts @@ -271,7 +271,7 @@ describe("POST /documents/:id/rotate-session", () => { await vi.advanceTimersByTimeAsync(10_000); - expect(deps.terminateOldSession).toHaveBeenCalledWith(documentId, oldSessionDid, "ddoc"); + expect(deps.terminateOldSession).toHaveBeenCalledWith(documentId, oldSessionDid); } finally { vi.useRealTimers(); } diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index 1dce662..f7e9188 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -148,27 +148,10 @@ describe("SessionManager", () => { // terminateSession - it("removes the session from memory and cleans up all related data in the database", async () => { - await sessionManager.terminateSession("doc-1", "session-1", "dsheet"); + it("marks the session terminated and keeps update + commit rows (all app types)", async () => { + await sessionManager.terminateSession("doc-1", "session-1"); expect(sessionManager.getLocalClients("doc-1", "session-1")).toBeUndefined(); - expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( - { documentId: "doc-1", sessionDid: "session-1" }, - { state: "terminated", roomInfo: null } - ); - expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ - documentId: "doc-1", - sessionDid: "session-1", - }); - expect(DocumentCommitModel.deleteMany).toHaveBeenCalledWith({ - documentId: "doc-1", - sessionDid: "session-1", - }); - }); - - it("ddoc terminate marks the session terminated but keeps updates + commits", async () => { - await sessionManager.terminateSession("doc-1", "session-1", "ddoc"); - expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( { documentId: "doc-1", sessionDid: "session-1" }, { state: "terminated", roomInfo: null } @@ -177,19 +160,6 @@ describe("SessionManager", () => { expect(DocumentCommitModel.deleteMany).not.toHaveBeenCalled(); }); - it("dsheet terminate keeps the cascade delete", async () => { - await sessionManager.terminateSession("doc-1", "session-1", "dsheet"); - - expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ - documentId: "doc-1", - sessionDid: "session-1", - }); - expect(DocumentCommitModel.deleteMany).toHaveBeenCalledWith({ - documentId: "doc-1", - sessionDid: "session-1", - }); - }); - // getOtherNonTerminatedSessions it("returns other non-terminated sessions (incl. inactive) excluding the given sessionDid", async () => { diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index df24bdc..60e0c82 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -265,6 +265,7 @@ describe("handleAuth", () => { ownerDid: "owner-did", ownerIdentityDid: "owner-identity-did", sessionDid: pinArgs.sessionDid, + appType: "ddoc", }); expect(fakeSessionManager.createSession).toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith( @@ -479,8 +480,7 @@ describe("handleAuth", () => { expect(oldSocket1.leave).toHaveBeenCalledWith(oldRoomName1); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( otherSessions[0].documentId, - otherSessions[0].sessionDid, - "ddoc" + otherSessions[0].sessionDid ); // Second other session (old-session-2) @@ -500,8 +500,7 @@ describe("handleAuth", () => { expect(oldSocket2.leave).toHaveBeenCalledWith(oldRoomName2); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( otherSessions[1].documentId, - otherSessions[1].sessionDid, - "ddoc" + otherSessions[1].sessionDid ); // Aggregate call-count assertions after verifying per-iteration sequence @@ -541,7 +540,7 @@ describe("handleAuth", () => { }); }); - it("terminates other sessions using their OWN appType, not the new connection's claimed appType", async () => { + it("terminates other sessions using their OWN sessionDid, not the new connection's claimed appType", async () => { const fakeIO = createFakeIO(); const fakeBroadcastOperator = { emit: vi.fn() }; const fakeSocket = createFakeSocket(fakeBroadcastOperator); @@ -575,13 +574,11 @@ describe("handleAuth", () => { await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); - // terminateSession must use the terminated session's own appType ("ddoc"), never - // the claimed appType of the new connection ("dsheet") — otherwise a dsheet - // re-auth would cascade-delete a ddoc's durable rows. + // terminateSession must target the terminated session's own identity, never + // anything derived from the new connection's claimed appType. expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( otherSession.documentId, - otherSession.sessionDid, - "ddoc" + otherSession.sessionDid ); }); @@ -617,6 +614,7 @@ describe("handleAuth", () => { const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect(fakeSocket.data.editPlaneEnforced).toBe(true); expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { action: "user_joined", user: { role: "editor" }, @@ -1715,6 +1713,328 @@ describe("handleAuth", () => { expect(fakeSocket.data.actorIdentityDid).toBeUndefined(); }); }); + + describe("dsheet owner bind (bind-if-presented)", () => { + const createArgs = (extra: Partial = {}): AuthArgs => ({ + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + appType: "dsheet", + ...extra, + }); + + it("creates an UNBOUND dsheet session when no identityToken is presented (legacy client)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs(), callback); + + expect(fakeAuthService.verifyIdentityToken).not.toHaveBeenCalled(); + expect(fakeSessionManager.createSession).toHaveBeenCalledWith( + expect.objectContaining({ appType: "dsheet", ownerIdentityDid: undefined }) + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("pins the documentId→portal binding for a dsheet create (unbound)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs(), callback); + + expect(fakeMongoDBStore.pinDocumentPortalIfAbsent).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: "doc-1", + portalAddress: "0x0000000000000000000000000000000000000002", + ownerIdentityDid: null, + appType: "dsheet", + }) + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("403s a dsheet create when the pinned portal differs, without creating a session", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: "0x9999999999999999999999999999999999999999", + }); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs(), callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 403, + error: "Document is owned by a different portal", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + }); + + it("binds the owner identity on a dsheet create when a valid identityToken is presented", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:owner-identity"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "id-token" }), callback); + + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith("id-token", "doc-1"); + expect(fakeSessionManager.createSession).toHaveBeenCalledWith( + expect.objectContaining({ appType: "dsheet", ownerIdentityDid: "did:key:owner-identity" }) + ); + expect(fakeMongoDBStore.pinDocumentPortalIfAbsent).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: "doc-1", + appType: "dsheet", + ownerIdentityDid: "did:key:owner-identity", + }) + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("401s a dsheet create when the presented identityToken is invalid (never downgraded to unbound)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue(null); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "bad-token" }), callback); + + 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("identity decides the role on a BOUND dsheet session join", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:owner-identity"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "id-token" }), callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "owner" }), + }) + ); + }); + + it("caps role to editor when the presented identity does not match the bound owner identity (dsheet)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:other-identity"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "id-token" }), callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + }); + + it("caps role to editor on a token-less join of a BOUND dsheet session", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs(), callback); + + expect(fakeAuthService.verifyIdentityToken).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + }); + + it("401s a BOUND dsheet join when the presented identityToken fails to verify", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue(null); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "bad-token" }), callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Invalid identity proof", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("heals an unbound dsheet session when a proven owner presents an identityToken", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, ownerIdentityDid: null, + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:owner-identity"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "id-token" }), callback); + + expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).toHaveBeenCalledWith( + "doc-1", "session-1", "did:key:owner-identity" + ); + }); + + it("kicks unenforced sockets already in the room on heal, spares already-enforced ones", async () => { + const unenforcedRemote = { id: "socket-unenforced", data: { editPlaneEnforced: false }, disconnect: vi.fn() }; + const enforcedRemote = { id: "socket-enforced", data: { editPlaneEnforced: true }, disconnect: vi.fn() }; + const fetchSockets = vi.fn().mockResolvedValue([unenforcedRemote, enforcedRemote]); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, ownerIdentityDid: null, + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:owner-identity"); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "id-token" }), callback); + + expect(unenforcedRemote.disconnect).toHaveBeenCalledWith(true); + expect(enforcedRemote.disconnect).not.toHaveBeenCalled(); + }); + + it("enforces rail admission on a BOUND dsheet session: no rail → JOIN_DISABLED", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(false); + + // Editor join: collaborationToken only — no ownerToken, no editUcan. + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", sessionDid: "session-1", + collaborationToken: "collab-token", appType: "dsheet", + }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + expect(fakeSocket.join).not.toHaveBeenCalled(); + }); + + it("admits a workspace-rail editor on a BOUND dsheet session when the tier is on", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + ownerIdentityDid: "did:key:owner-identity", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); // shared portal secret + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member-identity"); // not the creator + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + + await handleAuth(deps, fakeIO, fakeSocket, createArgs({ identityToken: "member-id-token" }), callback); + + expect(fakeSocket.join).toHaveBeenCalled(); + expect(fakeSocket.data.rail).toBe("workspace"); + expect(fakeSocket.data.editPlaneEnforced).toBe(true); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) + ); + }); + + it("stamps editPlaneEnforced=false on an UNBOUND dsheet join (legacy semantics preserved)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", + appType: "dsheet", collabJoinEnabled: false, + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", sessionDid: "session-1", + collaborationToken: "collab-token", appType: "dsheet", + }, callback); + + expect(fakeSocket.join).toHaveBeenCalled(); + expect(fakeSocket.data.editPlaneEnforced).toBe(false); + expect(fakeSessionManager.getCollabJoinEnabled).not.toHaveBeenCalled(); + }); + }); }); describe("handleAuth — edit-claim admission (existing session, non-owner)", () => { diff --git a/src/tests/services/socket-handlers.handleAwareness.test.ts b/src/tests/services/socket-handlers.handleAwareness.test.ts index 5346362..a6a0338 100644 --- a/src/tests/services/socket-handlers.handleAwareness.test.ts +++ b/src/tests/services/socket-handlers.handleAwareness.test.ts @@ -9,6 +9,7 @@ const defaultSocketData: SocketData = { role: "owner", authenticated: true, appType: "ddoc", + editPlaneEnforced: true, }; function createFakeIO(): AppServer { @@ -135,4 +136,23 @@ describe('handleAwareness', () => { await flush(); expect((socket as any).disconnect).not.toHaveBeenCalled(); }); + + it("disconnects a revoked editor on a BOUND dsheet session", async () => { + const deps = createDeps(); + deps.sessionManager.getCollabJoinEnabled.mockResolvedValue(false); + const socket = createFakeSocket(undefined, { role: "editor", appType: "dsheet", editPlaneEnforced: true, rail: "public" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect((socket as any).disconnect).toHaveBeenCalledWith(true); + }); + + it("does NOT guard an UNBOUND dsheet editor's awareness traffic (legacy semantics — must not disconnect)", async () => { + const deps = createDeps(); + deps.sessionManager.getCollabJoinEnabled.mockResolvedValue(false); // would revoke if the recheck ran + const socket = createFakeSocket(undefined, { role: "editor", appType: "dsheet", editPlaneEnforced: undefined, rail: "public" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect(deps.sessionManager.getCollabJoinEnabled).not.toHaveBeenCalled(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); + }); }); diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index f6c3cf1..32b0fa3 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -228,7 +228,7 @@ describe("handleDocumentUpdate", () => { }); }); - it("stamps the connection's appType onto the persisted update", async () => { + it("does not forward appType to createUpdate (the store stamps it from the session row)", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket({ emit: vi.fn() }); fakeSocket.data.appType = "dsheet"; @@ -257,7 +257,7 @@ describe("handleDocumentUpdate", () => { await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); expect(fakeMongoDBStore.createUpdate).toHaveBeenCalledWith( - expect.objectContaining({ appType: "dsheet" }) + expect.not.objectContaining({ appType: expect.anything() }) ); }); @@ -345,6 +345,7 @@ describe("handleDocumentUpdate", () => { ) { const socket = createFakeSocket(broadcast ?? { emit: vi.fn() }, { role: "editor" }); socket.data.rail = railData.rail; + socket.data.editPlaneEnforced = true; fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); return socket; @@ -391,9 +392,9 @@ describe("handleDocumentUpdate", () => { expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); }); - it("does NOT guard a dsheet editor (no rail model — must not 403)", async () => { + it("does NOT guard an UNBOUND dsheet editor (legacy semantics — must not 403)", async () => { const socket = createFakeSocket({ emit: vi.fn() }, { role: "editor" }); - socket.data.appType = "dsheet"; // excluded from the ddoc-only guard + socket.data.appType = "dsheet"; // no editPlaneEnforced stamp — unbound room, legacy semantics fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); @@ -402,6 +403,18 @@ describe("handleDocumentUpdate", () => { expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); }); + + it("kicks a revoked editor on a BOUND dsheet session (EDIT_REVOKED + disconnect)", async () => { + // Same setup as the ddoc revocation test in this file, but with a dsheet socket + // whose auth stamped editPlaneEnforced (bound room). + const socket = makeEditor({ rail: "public" }); + socket.data.appType = "dsheet"; + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(false); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect((socket as any).disconnect).toHaveBeenCalledWith(true); + }); }); }); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index c54be06..310c992 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -43,7 +43,7 @@ describe("handleSetDocumentMeta", () => { const cb = vi.fn(); await handleSetDocumentMeta(deps, fakeSocket("owner"), { editLock: "el", title: "t" }, cb); expect(upsertDocumentMeta).toHaveBeenCalledWith(expect.objectContaining({ - documentId: "doc-1", sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", editLock: "el", title: "t", + documentId: "doc-1", sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", editLock: "el", title: "t", appType: "ddoc", })); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); }); diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts index 0f95f1d..42afb9c 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -7,7 +7,7 @@ import { ErrorCode } from "../../types"; function fakeSocket(role: "owner" | "editor", extra: Record = {}): AppSocket { return { id: "socket-1", - data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc", ...extra }, + data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc", editPlaneEnforced: true, ...extra }, } as unknown as AppSocket; } @@ -80,4 +80,31 @@ describe("handleSnapshot", () => { await handleSnapshot(deps, fakeSocket("owner"), { data: "ct", collaborationToken: "t", floorSeq: 10 }, cb); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); }); + + it("kicks a revoked editor on a BOUND dsheet session (EDIT_REVOKED)", async () => { + getCollabJoinEnabled.mockResolvedValue(false); + const cb = vi.fn(); + await handleSnapshot( + deps, + fakeSocket("editor", { appType: "dsheet", editPlaneEnforced: true }), + { data: "ct", collaborationToken: "t", floorSeq: 1 }, + cb + ); + expect(createSnapshot).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + }); + + it("does NOT guard an UNBOUND dsheet editor's snapshot (legacy semantics — must not 403)", async () => { + getCollabJoinEnabled.mockResolvedValue(false); // would revoke if the recheck ran + createSnapshot.mockResolvedValue({ id: "s1", documentId: "doc-1", seq: 9 }); + const cb = vi.fn(); + await handleSnapshot( + deps, + fakeSocket("editor", { appType: "dsheet", editPlaneEnforced: undefined }), + { data: "ct", collaborationToken: "t", floorSeq: 4 }, + cb + ); + expect(createSnapshot).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); + }); }); diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index f732682..131ce44 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -248,8 +248,7 @@ describe("handleTerminateSession", () => { expect(fetchSocketsResponse[0].data.authenticated).toBe(false); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( fakeArgs.documentId, - fakeSessionResponse.sessionDid, - "ddoc" + fakeSessionResponse.sessionDid ); const callbackResponse = { @@ -260,11 +259,11 @@ describe("handleTerminateSession", () => { expect(callback).toHaveBeenCalledWith(callbackResponse); }); - it("uses the TARGET session's appType, not the calling socket's appType, when terminating cross-appType", async () => { + it("uses the TARGET session's own sessionDid, not the calling socket's, when terminating cross-appType", async () => { // Regression test: a socket last-authenticated to a "dsheet" session must not be able to - // use its own socket.data.appType when terminating a DIFFERENT target session/document - // that is actually a "ddoc". The cascade delete must be scoped to the target session's - // own stored appType, not whatever the calling socket happens to be. + // use its own socket.data when terminating a DIFFERENT target session/document + // that is actually a "ddoc". terminateSession must be scoped to the target session's + // own identity, not whatever the calling socket happens to be. const fetchSocketsResponse = [ { id: "peer-1", @@ -304,8 +303,7 @@ describe("handleTerminateSession", () => { expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( fakeArgs.documentId, - fakeSessionResponse.sessionDid, - "ddoc" + fakeSessionResponse.sessionDid ); }); @@ -343,8 +341,7 @@ describe("handleTerminateSession", () => { ); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( fakeArgs.documentId, - fakeSessionResponse.sessionDid, - "ddoc" + fakeSessionResponse.sessionDid ); expect(callback).toHaveBeenCalledWith({ status: true, diff --git a/src/types/index.ts b/src/types/index.ts index 74f27f8..a1d213a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -353,6 +353,9 @@ export interface SocketData { actorIdentityDid?: string; editEpoch?: number; lastAdmitRecheckAt?: number; + /** Rail admission + per-op revocation rechecks apply (ddoc always; dsheet iff the + * room has a bound owner identity — legacy dsheet rooms keep legacy semantics). */ + editPlaneEnforced?: boolean; } // ***************************************