Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/services/owner-op-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { Hex } from "viem";

export interface OwnerOpDeps {
authService: Pick<AuthService, "verifyOwnerOp">;
sessionManager: Pick<SessionManager, "getSession" | "setCollabJoinEnabled">;
sessionManager: Pick<
SessionManager,
"getSession" | "setCollabJoinEnabled" | "getNonTerminatedSessionsForDocument"
>;
}

export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) {
Expand All @@ -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<string>(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);
}
}
}

Expand Down
57 changes: 54 additions & 3 deletions src/services/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<RuntimeSession | undefined> {
// Check in-memory first for active sessions
const sessionKey = this.getSessionKey(documentId, sessionDid);
Expand Down Expand Up @@ -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<Array<{ documentId: string; sessionDid: string; appType?: "ddoc" | "dsheet" }>> {
try {
const query: Record<string, any> = { documentId, ownerDid, state: "active" };
const query: Record<string, any> = { documentId, ownerDid, state: { $ne: "terminated" } };
if (excludeSessionDid) {
query.sessionDid = { $ne: excludeSessionDid };
}
Expand All @@ -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<Array<{ sessionDid: string }>> {
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 [];
}
}
Expand Down
76 changes: 63 additions & 13 deletions src/services/socket-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,7 +250,7 @@ export async function handleAuth(
documentId,
sessionDid,
ownerDid,
ownerIdentityDid: args.ownerIdentityDid,
ownerIdentityDid: boundOwnerIdentityDid,
portalAddress: args.contractAddress,
collabJoinEnabled: false,
roomInfo: args.roomInfo,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion src/tests/services/owner-op-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]) })) };
});
Expand Down Expand Up @@ -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", () => {
Expand Down
31 changes: 27 additions & 4 deletions src/tests/services/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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 () => {
Expand Down
Loading
Loading