From a9380ffaa7b4f5ae338b0b78e99e2814c54e764f Mon Sep 17 00:00:00 2001 From: Nadeem Date: Wed, 15 Jul 2026 05:24:33 +0530 Subject: [PATCH 01/11] feat(collab): edit-permission admission + view-plane mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server-side enforcement for the GP/private edit rail and a fileKey- encrypted view-plane mirror, so the collab-server (not just the room key) governs who may write and lets viewers load the latest editor state. Edit admission & revocation: - verifyEditUcan: verify a gate-minted collab/EDIT UCAN against the pinned GATE_DID and extract its signed facts (editGrantEpoch, nullifier); the epoch comes from the token, never a client arg (auth.ts). - GateEpochCache: passive, single-flight, per-doc editGrantEpoch cache with expiry-on-read and monotonic writes — no timer, no background poll (gate-epoch.ts). - Rail-exclusive admission at /auth: an editUcan admits on the GP rail only (fresh epoch or 403, no fall-through); the public arm admits only when collabJoinEnabled === true; workspace via workspaceEditEnabled. Per-actor identity (nullifier / address) carried on the connection (socket-handlers). - Per-write chokepoint: reject ddoc writes with EDIT_REVOKED when a GP editor's admitted epoch is stale or the rail flag is off (owner bypass). - Owner ops (HTTP): POST /documents/:id/refresh-edit-grant (cache-bypass refetch + synchronous force-drop of stale GP sockets), workspace-edit-tier, and collab-join-enabled (owner-op-routes.ts). - Session flags workspaceEditEnabled / collabJoinEnabled default off (fail-closed); startup refuses to boot if GATE_DID is set without GATE_URL (config/index.ts, index.ts). View-plane mirror (latest-state for viewers): - document-mirror model + store: upsert, getLatest (ordered by server-stamped createdAt), purge (mongodb-store.ts). - handleMirrorSnapshot write handler at POST /documents/mirror-snapshot — any admitted editor (by rail), never broadcast; the fileKey ciphertext is the only boundary. - Open read GET /documents/:documentId/mirror (no auth — the ciphertext IS the boundary; the server never holds the fileKey). Config: adds GATE_URL, GATE_DID, COLLAB_SERVER_DID. Tests: vitest suite updated across handlers + new gate-epoch and handleMirrorSnapshot suites. Deploy: land AFTER the gate exposes the 0x03 edit role / editGrantEpoch; capabilities here stay inert (flags off, no editUcans minted) until the gate and client cutover ship. --- src/config/index.ts | 4 + src/database/models/document-mirror.ts | 25 +++ src/database/models/index.ts | 1 + src/database/models/session.ts | 2 + src/index.ts | 28 +++ src/services/auth.ts | 52 ++++- src/services/gate-epoch.ts | 58 ++++++ src/services/mongodb-store.ts | 31 ++- src/services/owner-op-routes.ts | 117 ++++++++++- src/services/session-manager.ts | 24 ++- src/services/socket-handlers.deps.ts | 2 + src/services/socket-handlers.ts | 143 +++++++++++-- src/tests/services/auth.test.ts | 47 ++++- src/tests/services/gate-epoch.test.ts | 111 ++++++++++ src/tests/services/mongodb-store.test.ts | 44 +++- src/tests/services/owner-op-routes.test.ts | 189 +++++++++++++++++- .../socket-handlers.handleAuth.test.ts | 153 ++++++++++++++ ...ocket-handlers.handleCommitHistory.test.ts | 1 + ...ocket-handlers.handleDisconnecting.test.ts | 1 + ...cket-handlers.handleDocumentCommit.test.ts | 1 + ...cket-handlers.handleDocumentUpdate.test.ts | 84 ++++++++ ...cket-handlers.handleMirrorSnapshot.test.ts | 63 ++++++ ...ket-handlers.handleSetDocumentMeta.test.ts | 1 + .../socket-handlers.handleSnapshot.test.ts | 1 + ...ocket-handlers.handleUpdateHistory.test.ts | 1 + .../socket-handlers.terminateSession.test.ts | 1 + src/types/index.ts | 17 ++ 27 files changed, 1179 insertions(+), 23 deletions(-) create mode 100644 src/database/models/document-mirror.ts create mode 100644 src/services/gate-epoch.ts create mode 100644 src/tests/services/gate-epoch.test.ts create mode 100644 src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index d46a45a..82106af 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -40,4 +40,8 @@ export const config: ServerConfig = { agenda: { concurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY || "1"), }, + gate: { + url: process.env.GATE_URL, + did: process.env.GATE_DID, + }, }; \ No newline at end of file diff --git a/src/database/models/document-mirror.ts b/src/database/models/document-mirror.ts new file mode 100644 index 0000000..8a4f491 --- /dev/null +++ b/src/database/models/document-mirror.ts @@ -0,0 +1,25 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +// The VIEW plane: fileKey-encrypted full-state snapshots (published-artifact shape) that +// viewers read load-on-open. Separate from DocumentUpdate (roomKey durability lane): different +// key, open read, epoch-keyed. One row per (documentId, fileKeyEpoch), keep-latest. +interface IDocumentMirror extends MongooseDocument { + documentId: string; + fileKeyEpoch: number; + data: string; + createdAt: number; + authorSessionDid: string; +} + +const DocumentMirrorSchema = new Schema({ + documentId: { type: String, required: true }, + fileKeyEpoch: { type: Number, required: true }, + data: { type: String, required: true }, + createdAt: { type: Number, required: true }, + authorSessionDid: { type: String, required: true }, +}); + +DocumentMirrorSchema.index({ documentId: 1, fileKeyEpoch: 1 }, { unique: true, background: true }); + +export const DocumentMirrorModel = mongoose.model("DocumentMirror", DocumentMirrorSchema); +export type { IDocumentMirror }; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index eaf1e52..cc43795 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -3,3 +3,4 @@ export { DocumentCommitModel, type IDocumentCommit } from "./document-commit"; export { SessionModel, type ISession } from "./session"; export { CounterModel, type ICounter } from "./counter"; export { DocumentMetaModel, type IDocumentMeta } from "./document-meta"; +export { DocumentMirrorModel, type IDocumentMirror } from "./document-mirror"; diff --git a/src/database/models/session.ts b/src/database/models/session.ts index 2948afb..804101d 100644 --- a/src/database/models/session.ts +++ b/src/database/models/session.ts @@ -11,6 +11,7 @@ export interface ISession extends MongooseDocument { ownerIdentityDid: string | null; portalAddress: string | null; collabJoinEnabled?: boolean; + workspaceEditEnabled?: boolean; } const SessionSchema = new Schema({ @@ -26,6 +27,7 @@ const SessionSchema = new Schema({ ownerIdentityDid: { type: String, default: null }, portalAddress: { type: String, default: null }, collabJoinEnabled: { type: Boolean }, // intentionally no default + workspaceEditEnabled: { type: Boolean }, // intentionally no default }); SessionSchema.index({ documentId: 1, createdAt: 1, sessionDid: 1 }); diff --git a/src/index.ts b/src/index.ts index a9c9fe1..e67e03b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,9 +15,13 @@ import { createRedisAdapter } from "./redis"; import { databaseService } from "./database"; import { createCollabJoinEnabledHandler, + createWorkspaceEditTierHandler, + createRefreshEditGrantHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, + createMirrorReadHandler, } from "./services/owner-op-routes"; +import { gateEpochCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; @@ -95,6 +99,18 @@ class CollaborationServer { "/documents/:documentId/collab-join-enabled", createCollabJoinEnabledHandler({ authService, sessionManager }, this.io) ); + this.app.post( + "/documents/:documentId/workspace-edit-tier", + createWorkspaceEditTierHandler({ authService, sessionManager }, this.io) + ); + this.app.post( + "/documents/:documentId/refresh-edit-grant", + createRefreshEditGrantHandler({ authService, sessionManager, gateEpochCache }, this.io) + ); + this.app.get( + "/documents/:documentId/mirror", + createMirrorReadHandler({ mongodbStore }) + ); this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); this.app.delete( @@ -124,6 +140,18 @@ class CollaborationServer { async start() { try { + if (!config.gate.did) { + console.warn( + "[startup] GATE_DID is not set — GP (private/group) live editing is DISABLED; only owner/workspace/public rails admit writes." + ); + } else if (!config.gate.url) { + // GATE_DID without GATE_URL: verifyEditUcan admits GP joins but the epoch cache can + // never reach the gate, so every revocation (demote/revoke) is a permanent silent no-op. + throw new Error( + "[startup] GATE_DID is set but GATE_URL is not — GP editGrantEpoch revocation would never take effect. Set GATE_URL to the gate origin, or unset GATE_DID to disable GP editing." + ); + } + // Initialize database connection await databaseService.connect(); diff --git a/src/services/auth.ts b/src/services/auth.ts index 4313f31..c1228c9 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -2,13 +2,16 @@ import * as ucans from "@ucans/ucans"; import { getIdentitySigningDid, getOwnerDid } from "../utils/contract"; import { Hex } from "viem"; import NodeCache from "node-cache"; +import { config } from "../config"; export class AuthService { private serverDid: string; + private gateDid?: string; private collaborationTokenCache = new NodeCache({ stdTTL: 3600 }); - constructor(serverDid: string) { + constructor(serverDid: string, gateDid?: string) { this.serverDid = serverDid; + this.gateDid = gateDid; } getServerDid(): string { @@ -75,6 +78,50 @@ export class AuthService { } } + /** + * Verify a gate-minted edit-admission UCAN, rooted at the pinned gate DID. Returns the + * signed facts (editGrantEpoch, nullifier) or null. The epoch comes from the token, never a client arg. + */ + async verifyEditUcan( + token: string, + documentId: string + ): Promise<{ editGrantEpoch: number; nullifier: string } | null> { + if (!this.gateDid) return null; // GP editing disabled until GATE_DID is pinned + try { + const result = await ucans.verify(token, { + audience: this.serverDid, + requiredCapabilities: [ + { + capability: { + with: { scheme: "collab", hierPart: documentId }, + can: { namespace: "collab", segments: ["EDIT"] }, + }, + rootIssuer: this.gateDid, + }, + ], + }); + if (!result.ok) return null; + + const parsed = await ucans.validate(token); + const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { + docId?: string; + editGrantEpoch?: number; + nullifier?: string; + }; + if ( + fact.docId !== documentId || + typeof fact.editGrantEpoch !== "number" || + typeof fact.nullifier !== "string" + ) { + return null; + } + return { editGrantEpoch: fact.editGrantEpoch, nullifier: fact.nullifier }; + } catch (error) { + console.error("Edit UCAN verification error:", error); + return null; + } + } + async verifyIdentityToken( token: string, identityContractAddress: Hex, @@ -138,5 +185,6 @@ export class AuthService { } export const authService = new AuthService( - process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + config.gate.did ); diff --git a/src/services/gate-epoch.ts b/src/services/gate-epoch.ts new file mode 100644 index 0000000..6436d38 --- /dev/null +++ b/src/services/gate-epoch.ts @@ -0,0 +1,58 @@ +import { config } from "../config"; + +const TTL_MS = 30_000; + +/** + * Passive single-flight cache of the gate's per-doc editGrantEpoch. No timer: entries + * expire on read (fetchedAt + TTL) and concurrent misses coalesce into one fetch. + */ +export class GateEpochCache { + private cache = new Map(); + private inflight = new Map>(); + + constructor(private gateUrl: string | undefined = config.gate.url) {} + + private async rawFetch(documentId: string): Promise { + if (!this.gateUrl) return null; + try { + const res = await fetch(`${this.gateUrl}/gate/doc/${documentId}/edit-grant-epoch`); + if (!res.ok) return null; + const body = (await res.json()) as { editGrantEpoch?: number }; + if (typeof body.editGrantEpoch !== "number") return null; + // editGrantEpoch only ever increases; a slow read that started before a bump must + // not overwrite a fresher cached value with its stale epoch. + const existing = this.cache.get(documentId); + if (!existing || body.editGrantEpoch >= existing.epoch) { + this.cache.set(documentId, { epoch: body.editGrantEpoch, fetchedAt: Date.now() }); + } + return body.editGrantEpoch; + } catch (error) { + console.error("Gate epoch fetch error:", error); + return null; + } + } + + private fetchEpoch(documentId: string): Promise { + if (!this.gateUrl) return Promise.resolve(null); + const existing = this.inflight.get(documentId); + if (existing) return existing; // single-flight: coalesce concurrent misses + const p = this.rawFetch(documentId).finally(() => this.inflight.delete(documentId)); + this.inflight.set(documentId, p); + return p; + } + + /** Cached read (≤TTL, expiry-on-read); zero network on a fresh hit. */ + async getEditGrantEpoch(documentId: string): Promise { + const hit = this.cache.get(documentId); + if (hit && Date.now() - hit.fetchedAt < TTL_MS) return hit.epoch; + return this.fetchEpoch(documentId); + } + + /** Force-drop path: must observe the post-bump epoch, so fetch directly and bypass the + * single-flight coalescing that could hand back a pre-bump in-flight read. */ + async refreshEditGrantEpoch(documentId: string): Promise { + return this.rawFetch(documentId); + } +} + +export const gateEpochCache = new GateEpochCache(); diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index cff692e..8a64fa3 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,5 +1,5 @@ import { DocumentUpdate, DocumentCommit } from "../types/index"; -import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel } from "../database/models"; +import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel } from "../database/models"; export class MongoDBStore { // Update management @@ -161,6 +161,34 @@ export class MongoDBStore { return { ...snapshot, seq, updateType: "snapshot" }; } + /** + * View-plane keep-latest: one row per (documentId, fileKeyEpoch), overwritten in place. + * fileKeyEpoch is client-asserted, so it never drives a destructive prune — recency comes + * from the server-stamped createdAt at read time. + */ + async upsertMirrorSnapshot(mirror: { + documentId: string; + data: string; + fileKeyEpoch: number; + sessionDid: string; + createdAt: number; + }): Promise { + await DocumentMirrorModel.findOneAndUpdate( + { documentId: mirror.documentId, fileKeyEpoch: mirror.fileKeyEpoch }, + { $set: { data: mirror.data, createdAt: mirror.createdAt, authorSessionDid: mirror.sessionDid } }, + { upsert: true } + ); + } + + /** The most recently written mirror snapshot (server-stamped createdAt), or null. Open read. */ + async getLatestMirror( + documentId: string + ): Promise<{ data: string; fileKeyEpoch: number; createdAt: number } | null> { + const row: any = await DocumentMirrorModel.findOne({ documentId }).sort({ createdAt: -1 }).lean(); + if (!row) return null; + return { data: row.data, fileKeyEpoch: row.fileKeyEpoch, createdAt: row.createdAt }; + } + async getHydrationRange( documentId: string, sessionDid: string, @@ -411,6 +439,7 @@ export class MongoDBStore { DocumentMetaModel.deleteOne({ _id: documentId }), SessionModel.deleteMany({ documentId }), CounterModel.deleteOne({ _id: documentId }), + DocumentMirrorModel.deleteMany({ documentId }), ]); } diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 6192c19..1739f7a 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -3,6 +3,7 @@ import type { AppServer } from "../types/index"; import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; +import type { GateEpochCache } from "./gate-epoch"; import { getRoomName } from "./socket-handlers"; import { Hex } from "viem"; @@ -10,7 +11,7 @@ export interface OwnerOpDeps { authService: Pick; sessionManager: Pick< SessionManager, - "getSession" | "setCollabJoinEnabled" | "getNonTerminatedSessionsForDocument" + "getSession" | "setCollabJoinEnabled" | "setWorkspaceEditEnabled" | "getNonTerminatedSessionsForDocument" >; } @@ -45,8 +46,9 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); const sessionDids = new Set(targets.map((t) => t.sessionDid)); sessionDids.add(sessionDid); + let updated = 0; for (const sd of sessionDids) { - await deps.sessionManager.setCollabJoinEnabled(documentId, sd, !!enabled); + if (await deps.sessionManager.setCollabJoinEnabled(documentId, sd, !!enabled)) updated++; } if (enabled === false) { @@ -62,10 +64,107 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) } } + res.status(200).json({ ok: true, updated }); + }; +} + +export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, enabled, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, identityContractAddress: identityContractAddress as Hex, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Doc-wide, mirroring collab-join-enabled: the flag lives on every session so a joiner + // reads it whatever session its roomKey derives. + 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.setWorkspaceEditEnabled(documentId, sd, !!enabled); + } + + if (enabled === false) { + // Synchronous revocation: drop live workspace-rail sockets only — public/GP editors + // are governed by their own rails. + 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.rail === "workspace") s.disconnect(true); + } + } + } + res.status(200).json({ ok: true }); }; } +export function createRefreshEditGrantHandler( + deps: OwnerOpDeps & { gateEpochCache: GateEpochCache }, + io: AppServer +) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, identityContractAddress: identityContractAddress as Hex, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Post-bump epoch straight from the gate (cache-bypassing), then synchronously drop every + // GP-rail socket admitted below it. Still-authorized editors reconnect and re-mint a fresh + // grant; the demoted one holds only a view/comment share and cannot rejoin as an editor. + // Gate unreachable (null) ⇒ skip the drop; the chokepoint backstop still blocks stale writers. + const currentEpoch = await deps.gateEpochCache.refreshEditGrantEpoch(documentId); + if (currentEpoch !== null) { + const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); + const sessionDids = new Set(targets.map((t) => t.sessionDid)); + sessionDids.add(sessionDid); + for (const sd of sessionDids) { + const sockets = await io.in(getRoomName(documentId, sd)).fetchSockets(); + for (const s of sockets) { + if (s.data.rail === "gp" && (s.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { + s.disconnect(true); + } + } + } + } + + res.status(200).json({ ok: true, editGrantEpoch: currentEpoch }); + }; +} + export interface ListMyDocumentsDeps { authService: Pick; mongodbStore: Pick; @@ -130,3 +229,17 @@ export function createDeleteDocumentHandler(deps: { res.status(200).json({ ok: true }); }; } + +export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { + return async (req: Request, res: Response): Promise => { + // Open read: the payload is fileKey-ciphertext, useless without the key (public → linkKey; + // private → gate/appLock). Access control is the key, not this endpoint. + const documentId = req.params.documentId; + const mirror = await deps.mongodbStore.getLatestMirror(documentId); + if (!mirror) { + res.status(404).json({ error: "No mirror snapshot" }); + return; + } + res.status(200).json(mirror); + }; +} diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 47adc80..3e2a284 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -10,6 +10,7 @@ interface RuntimeSession { ownerIdentityDid?: string; portalAddress?: string; collabJoinEnabled?: boolean; + workspaceEditEnabled?: boolean; } export class SessionManager { @@ -132,6 +133,7 @@ export class SessionManager { ownerIdentityDid: dbSession.ownerIdentityDid ?? undefined, portalAddress: dbSession.portalAddress ?? undefined, collabJoinEnabled: dbSession.collabJoinEnabled, + workspaceEditEnabled: dbSession.workspaceEditEnabled, }; // Store in memory @@ -145,14 +147,32 @@ export class SessionManager { return (doc as any)?.collabJoinEnabled; } - async setCollabJoinEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + async setCollabJoinEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { const sessionKey = this.getSessionKey(documentId, sessionDid); const session = this.inMemorySessions.get(sessionKey); if (session) session.collabJoinEnabled = enabled; try { - await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { collabJoinEnabled: enabled }); + const res = await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { collabJoinEnabled: enabled }); + return res !== null || session !== undefined; } catch (error) { console.error("Error setting collabJoinEnabled:", error); + return false; + } + } + + async getWorkspaceEditEnabled(documentId: string, sessionDid: string): Promise { + const doc = await SessionModel.findOne({ documentId, sessionDid }, { workspaceEditEnabled: 1 }).lean(); + return (doc as any)?.workspaceEditEnabled; + } + + async setWorkspaceEditEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + const sessionKey = this.getSessionKey(documentId, sessionDid); + const session = this.inMemorySessions.get(sessionKey); + if (session) session.workspaceEditEnabled = enabled; + try { + await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { workspaceEditEnabled: enabled }); + } catch (error) { + console.error("Error setting workspaceEditEnabled:", error); } } diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts index 5a09831..37c4506 100644 --- a/src/services/socket-handlers.deps.ts +++ b/src/services/socket-handlers.deps.ts @@ -1,9 +1,11 @@ import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; +import type { GateEpochCache } from "./gate-epoch"; export interface SocketHandlerDeps { authService: AuthService; sessionManager: SessionManager; mongodbStore: MongoDBStore; + gateEpochCache: GateEpochCache; } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 3ff7e4e..dccd99b 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -11,6 +11,7 @@ import { UpdateHistoryArgs, UpdateHistoryResponseData, SnapshotArgs, + MirrorSnapshotArgs, DocumentMetaArgs, PeersListArgs, AwarenessArgs, @@ -25,6 +26,7 @@ import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; import { mongodbStore } from "./mongodb-store"; import { sessionManager } from "./session-manager"; +import { gateEpochCache } from "./gate-epoch"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; @@ -32,6 +34,7 @@ const defaultDeps: SocketHandlerDeps = { authService, sessionManager, mongodbStore, + gateEpochCache, }; function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { @@ -81,6 +84,9 @@ export function registerEventHandlers(io: AppServer): void { socket.on("/documents/snapshot", (args, callback) => handleSnapshot(defaultDeps, socket, args, callback) ); + socket.on("/documents/mirror-snapshot", (args, callback) => + handleMirrorSnapshot(defaultDeps, socket, args, callback) + ); socket.on("/documents/meta", (args, callback) => handleSetDocumentMeta(defaultDeps, socket, args, callback) ); @@ -146,6 +152,9 @@ export async function handleAuth( let sessionType: "new" | "existing"; let roomInfo: string | undefined; let resolvedAppType: AppType; + let rail: "gp" | "workspace" | "public" | undefined = undefined; + let admittedEditGrantEpoch: number | undefined = undefined; + let actorHandle: string | undefined = undefined; if (!existingSession && args.ownerToken) { // - Set up a new session (owner flow) @@ -336,19 +345,49 @@ export async function handleAuth( } } - // 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). - // Only an explicit `false` closes a ddoc room to non-owners; undefined (legacy) stays open; owners bypass. + // Rail-exclusive edit admission (ddoc-only, 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. if (storedAppType === "ddoc" && role === "editor") { - const joinEnabled = await sessionManager.getCollabJoinEnabled(documentId, existingSession.sessionDid); - if (joinEnabled === false) { - return callback({ - status: false, - statusCode: 403, - error: "Link sharing is disabled for this document", - errorCode: ErrorCode.JOIN_DISABLED, - }); + if (args.editUcan) { + const gp = await authService.verifyEditUcan(args.editUcan, documentId); + const currentEpoch = gp ? await deps.gateEpochCache.getEditGrantEpoch(documentId) : null; + if (gp && (currentEpoch === null || gp.editGrantEpoch >= currentEpoch)) { + rail = "gp"; + admittedEditGrantEpoch = gp.editGrantEpoch; + actorHandle = gp.nullifier; + } else { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + } else if ( + // Gates a collaborator whose portal DID differs from the doc owner's. A team member + // sharing the portal's collaborator DID resolves as owner above and never reaches here, + // so the tier is not a per-member edit boundary for shared-DID teams (awaits MemberSA). + ownerDid && + (await sessionManager.getWorkspaceEditEnabled(documentId, existingSession.sessionDid)) === true + ) { + rail = "workspace"; + actorHandle = args.ownerAddress?.toLowerCase(); + } else { + // Read fresh from Mongo (an HTTP process can flip this; the in-memory copy may be + // stale). Only an explicit `true` opens a ddoc room to a non-GP, non-workspace bearer. + const joinEnabled = await sessionManager.getCollabJoinEnabled(documentId, existingSession.sessionDid); + if (joinEnabled !== true) { + return callback({ + status: false, + statusCode: 403, + error: "Link sharing is disabled for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + rail = "public"; + actorHandle = args.actorHandle; } } @@ -379,6 +418,9 @@ export async function handleAuth( socket.data.sessionDid = sessionDid; socket.data.role = role; socket.data.appType = resolvedAppType; + socket.data.rail = rail; + socket.data.admittedEditGrantEpoch = admittedEditGrantEpoch; + socket.data.actorHandle = actorHandle; // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); @@ -475,6 +517,35 @@ export async function handleDocumentUpdate( }); } + if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + const rail = socket.data.rail; + let revoked = false; + if (rail === "gp") { + const currentEpoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); + if (currentEpoch !== null && (socket.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { + revoked = true; + } + } else if (rail === "workspace") { + if ((await sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) !== true) { + revoked = true; + } + } else { + // public / no rail: no epoch to check; only an explicit true still authorizes. + // false or a legacy-undefined flag both revoke, matching admission. + if ((await sessionManager.getCollabJoinEnabled(documentId, sessionDid)) !== true) { + revoked = true; + } + } + if (revoked) { + return callback({ + status: false, + statusCode: 403, + error: "Edit access has been revoked", + errorCode: ErrorCode.EDIT_REVOKED, + }); + } + } + const updateId = uuidv4(); const createdAt = Date.now(); @@ -823,6 +894,54 @@ export async function handleSnapshot( } } +// fileKeyEpoch is a client-asserted rotation counter (0 until a private narrow rotates the +// fileKey). The mirror read returns the highest epoch, so an out-of-range value would shadow +// every legitimate write; bound it generously here, at the trust boundary. +const MAX_FILE_KEY_EPOCH = 1_000_000; + +export async function handleMirrorSnapshot( + deps: SocketHandlerDeps, + socket: AppSocket, + args: MirrorSnapshotArgs, + callback: (response: AckResponse<{ ok: true }>) => void +): Promise { + try { + const { sessionManager, mongodbStore } = deps; + if (!requireAuth(socket)) { + return callback({ status: false, statusCode: 401, error: "Not authenticated", errorCode: ErrorCode.NOT_AUTHENTICATED }); + } + // Any admitted editor may author the mirror: the owner, or a rail-admitted non-owner + // editor (socket.data.rail set at JOIN). Viewers never socket-connect (load-on-open). + if (socket.data.role !== "owner" && !socket.data.rail) { + return callback({ status: false, statusCode: 403, error: "Not an editor", errorCode: ErrorCode.COMMIT_UNAUTHORIZED }); + } + + const { data, fileKeyEpoch } = args; + const documentId = args.documentId || socket.data.documentId; + if (!data || !Number.isSafeInteger(fileKeyEpoch) || fileKeyEpoch < 0 || fileKeyEpoch > MAX_FILE_KEY_EPOCH) { + return callback({ status: false, statusCode: 400, error: "Mirror data and an in-range non-negative integer fileKeyEpoch are required", errorCode: ErrorCode.UPDATE_DATA_MISSING }); + } + + const session = await sessionManager.getRuntimeSession(documentId, socket.data.sessionDid); + if (!session) { + return callback({ status: false, statusCode: 404, error: "Session not found", errorCode: ErrorCode.SESSION_NOT_FOUND }); + } + + await mongodbStore.upsertMirrorSnapshot({ + documentId, + data, + fileKeyEpoch, + sessionDid: session.sessionDid, + createdAt: Date.now(), + }); + + callback({ status: true, statusCode: 200, data: { ok: true } }); + } catch (error) { + console.error("Error in mirror snapshot handler:", error); + callback({ status: false, statusCode: 500, error: "Internal server error", errorCode: ErrorCode.INTERNAL_ERROR }); + } +} + export async function handleSetDocumentMeta( deps: SocketHandlerDeps, socket: AppSocket, diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 760a67e..56ace4f 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -3,7 +3,7 @@ import * as ucans from "@ucans/ucans"; import { AuthService } from "../../services/auth"; import { getIdentitySigningDid, getOwnerDid } from "../../utils/contract"; -vi.mock("@ucans/ucans", () => ({ verify: vi.fn() })); +vi.mock("@ucans/ucans", () => ({ verify: vi.fn(), validate: vi.fn() })); vi.mock("../../utils/contract", () => ({ getIdentitySigningDid: vi.fn(), getOwnerDid: vi.fn(), @@ -115,3 +115,48 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { expect(spy).toHaveBeenCalledWith("it", "0xIdentity", "ddoc-A"); }); }); + +describe("verifyEditUcan", () => { + const serverDid = "did:key:zServerTest"; + const gateDid = "did:key:zGateTest"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the facts when verify passes and the fact docId matches", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editGrantEpoch: 3, nullifier: "n-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ editGrantEpoch: 3, nullifier: "n-1" }); + // rooted at the pinned gate DID + the collab:EDIT capability + expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ + audience: serverDid, + requiredCapabilities: [expect.objectContaining({ + capability: { with: { scheme: "collab", hierPart: "doc-1" }, can: { namespace: "collab", segments: ["EDIT"] } }, + rootIssuer: gateDid, + })], + })); + }); + + it("returns null when verify fails (wrong issuer / capability / audience)", async () => { + (ucans.verify as any).mockResolvedValue({ ok: false }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + // verify gates validate: a failed verify must not reach fact extraction. + expect(ucans.validate).not.toHaveBeenCalled(); + }); + + it("returns null when the fact docId disagrees with the requested documentId", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-OTHER", editGrantEpoch: 3, nullifier: "n-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + }); + + it("returns null (short-circuit, no verify call) when no gate DID is pinned", async () => { + const svc = new AuthService(serverDid, undefined); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + expect(ucans.verify).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/gate-epoch.test.ts b/src/tests/services/gate-epoch.test.ts new file mode 100644 index 0000000..77edee1 --- /dev/null +++ b/src/tests/services/gate-epoch.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { GateEpochCache } from "../../services/gate-epoch"; + +describe("GateEpochCache", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fetches, returns, and caches the editGrantEpoch", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ editGrantEpoch: 7 }) }); + vi.stubGlobal("fetch", fetchMock); + const cache = new GateEpochCache("https://gate.test"); + + expect(await cache.getEditGrantEpoch("doc-1")).toBe(7); + expect(await cache.getEditGrantEpoch("doc-1")).toBe(7); // cache hit — no second fetch + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith("https://gate.test/gate/doc/doc-1/edit-grant-epoch"); + }); + + it("refreshEditGrantEpoch bypasses the cache", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); + vi.stubGlobal("fetch", fetchMock); + const cache = new GateEpochCache("https://gate.test"); + + expect(await cache.getEditGrantEpoch("doc-1")).toBe(1); + expect(await cache.refreshEditGrantEpoch("doc-1")).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns null on a non-ok response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }); + vi.stubGlobal("fetch", fetchMock); + expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); + }); + + it("returns null WITHOUT calling fetch when no gate URL is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + expect(await new GateEpochCache(undefined).getEditGrantEpoch("doc-x")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns null when the response body has a non-number editGrantEpoch", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ editGrantEpoch: "7" }) }); + vi.stubGlobal("fetch", fetchMock); + expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); + vi.stubGlobal("fetch", fetchMock); + expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); + }); + + it("coalesces concurrent misses for the same doc into ONE fetch (single-flight)", async () => { + let resolveFetch!: (v: unknown) => void; + const fetchMock = vi.fn().mockReturnValue(new Promise((r) => (resolveFetch = r))); + vi.stubGlobal("fetch", fetchMock); + const cache = new GateEpochCache("https://gate.test"); + + const p1 = cache.getEditGrantEpoch("doc-1"); + const p2 = cache.getEditGrantEpoch("doc-1"); // second miss before the first resolves + resolveFetch({ ok: true, json: async () => ({ editGrantEpoch: 9 }) }); + + expect(await p1).toBe(9); + expect(await p2).toBe(9); + expect(fetchMock).toHaveBeenCalledTimes(1); // both awaited the same in-flight fetch + }); + + it("refreshEditGrantEpoch does NOT piggyback on an in-flight cache read (observes the post-bump epoch)", async () => { + let resolveFirst!: (v: unknown) => void; + const fetchMock = vi + .fn() + .mockReturnValueOnce(new Promise((r) => (resolveFirst = r))) // in-flight get, pre-bump + .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); // refresh's own fetch, post-bump + vi.stubGlobal("fetch", fetchMock); + const cache = new GateEpochCache("https://gate.test"); + + const getP = cache.getEditGrantEpoch("doc-1"); // starts in-flight fetch #1 + const refreshP = cache.refreshEditGrantEpoch("doc-1"); // must NOT coalesce + resolveFirst({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }); + + expect(await refreshP).toBe(2); // saw the post-bump epoch, not the stale in-flight 1 + expect(await getP).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // two independent fetches, no coalescing + }); + + it("a stale in-flight read does not clobber a fresher cached epoch (monotonic cache)", async () => { + let resolveStale!: (v: unknown) => void; + const fetchMock = vi + .fn() + .mockReturnValueOnce(new Promise((r) => (resolveStale = r))) // get → resolves to stale 1 + .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); // refresh → fresh 2 + vi.stubGlobal("fetch", fetchMock); + const cache = new GateEpochCache("https://gate.test"); + + const getP = cache.getEditGrantEpoch("doc-1"); // in-flight, will resolve to 1 + await cache.refreshEditGrantEpoch("doc-1"); // fetches 2, caches 2 + resolveStale({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }); + await getP; // stale 1 resolves; monotonic guard must NOT overwrite cached 2 + + expect(await cache.getEditGrantEpoch("doc-1")).toBe(2); // served from cache, still 2 + expect(fetchMock).toHaveBeenCalledTimes(2); // the cache-hit read did not re-fetch + }); +}); diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 10472bb..f08d422 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -22,6 +22,11 @@ vi.mock("../../database/models", () => { CounterModel: { findOneAndUpdate: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, SessionModel: { findOne: vi.fn(), find: vi.fn(), deleteMany: vi.fn().mockResolvedValue(undefined) }, DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), deleteOne: vi.fn().mockResolvedValue(undefined) }, + DocumentMirrorModel: { + findOneAndUpdate: vi.fn().mockResolvedValue(undefined), + findOne: vi.fn(), + deleteMany: vi.fn().mockResolvedValue(undefined), + }, }; }); @@ -151,6 +156,40 @@ describe("createSnapshot: keep-latest", () => { }); }); +describe("mirror lane", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("upserts keyed by (documentId, fileKeyEpoch), overwrite-in-place with NO destructive prune", async () => { + const { DocumentMirrorModel } = await import("../../database/models"); + const store = new MongoDBStore(); + await store.upsertMirrorSnapshot({ documentId: "doc-1", data: "ct", fileKeyEpoch: 4, sessionDid: "sess-1", createdAt: 111 }); + + expect(DocumentMirrorModel.findOneAndUpdate).toHaveBeenCalledWith( + { documentId: "doc-1", fileKeyEpoch: 4 }, + { $set: { data: "ct", createdAt: 111, authorSessionDid: "sess-1" } }, + { upsert: true } + ); + // A client-asserted epoch must NEVER trigger a delete of other rows. + expect(DocumentMirrorModel.deleteMany).not.toHaveBeenCalled(); + }); + + it("getLatestMirror returns the most recent row (by createdAt) or null", async () => { + const { DocumentMirrorModel } = await import("../../database/models"); + const lean = vi.fn().mockResolvedValue({ documentId: "doc-1", fileKeyEpoch: 9, data: "ct", createdAt: 222 }); + const sort = vi.fn().mockReturnValue({ lean }); + (DocumentMirrorModel.findOne as any).mockReturnValue({ sort }); + const store = new MongoDBStore(); + expect(await store.getLatestMirror("doc-1")).toEqual({ data: "ct", fileKeyEpoch: 9, createdAt: 222 }); + expect(DocumentMirrorModel.findOne).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(sort).toHaveBeenCalledWith({ createdAt: -1 }); + + (DocumentMirrorModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); + expect(await store.getLatestMirror("doc-1")).toBeNull(); + }); +}); + describe("upsertDocumentMeta", () => { beforeEach(() => { vi.clearAllMocks(); @@ -369,8 +408,8 @@ describe("purgeDocument", () => { vi.clearAllMocks(); }); - it("wipes all five collections for the documentId", async () => { - const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel } = + it("wipes all six collections for the documentId", async () => { + const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel } = await import("../../database/models"); const store = new MongoDBStore(); @@ -381,6 +420,7 @@ describe("purgeDocument", () => { expect(DocumentMetaModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); expect(SessionModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); expect(CounterModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); + expect(DocumentMirrorModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); }); }); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index b3536c3..374a1de 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler } from "../../services/owner-op-routes"; +import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createWorkspaceEditTierHandler } from "../../services/owner-op-routes"; +import { createRefreshEditGrantHandler, createMirrorReadHandler } from "../../services/owner-op-routes"; +import { getRoomName } from "../../services/socket-handlers"; function res() { const r: any = {}; @@ -77,6 +79,75 @@ describe("POST collab-join-enabled", () => { }); }); +describe("POST workspace-edit-tier", () => { + let deps: any, io: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn(), + setWorkspaceEditEnabled: vi.fn(), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([]), + }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("403s when verifyOwnerOp rejects", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: true } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.sessionManager.setWorkspaceEditEnabled).not.toHaveBeenCalled(); + }); + + it("enables the tier for the proven owner", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s" }]); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: true } } as any, r + ); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s", true); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("on disable, force-drops ONLY workspace-rail sockets (not public editors or owners)", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s" }]); + const wsSock: any = { data: { role: "editor", rail: "workspace" }, disconnect: vi.fn() }; + const pubSock: any = { data: { role: "editor", rail: "public" }, disconnect: vi.fn() }; + const ownerSock: any = { data: { role: "owner" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([wsSock, pubSock, ownerSock]) })); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: false } } as any, r + ); + expect(wsSock.disconnect).toHaveBeenCalledWith(true); + expect(pubSock.disconnect).not.toHaveBeenCalled(); + expect(ownerSock.disconnect).not.toHaveBeenCalled(); + }); + + it("writes the flag to every non-terminated session plus the request one (doc-wide)", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s-a" }, { sessionDid: "s-b" }]); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-a", true); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-b", true); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-req", true); + }); +}); + describe("POST /list-my-documents", () => { let deps: any; beforeEach(() => { @@ -160,3 +231,119 @@ describe("DELETE /documents/:id", () => { expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); }); }); + +describe("POST refresh-edit-grant", () => { + let deps: any, io: any, gateEpochCache: any; + beforeEach(() => { + vi.clearAllMocks(); + gateEpochCache = { refreshEditGrantEpoch: vi.fn() }; + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn().mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([{ sessionDid: "s" }]), + }, + gateEpochCache, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("403s when verifyOwnerOp rejects (and never touches the gate)", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createRefreshEditGrantHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(403); + expect(gateEpochCache.refreshEditGrantEpoch).not.toHaveBeenCalled(); + }); + + it("404s when the session is unknown", async () => { + deps.sessionManager.getSession.mockResolvedValue(null); + const r = res(); + await createRefreshEditGrantHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(404); + expect(gateEpochCache.refreshEditGrantEpoch).not.toHaveBeenCalled(); + }); + + it("refreshes the epoch and force-drops ONLY stale GP sockets (not fresh GP / workspace / owner)", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(6); + const staleGp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const freshGp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 6 }, disconnect: vi.fn() }; + const ws: any = { data: { role: "editor", rail: "workspace", admittedEditGrantEpoch: 0 }, disconnect: vi.fn() }; + const owner: any = { data: { role: "owner" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([staleGp, freshGp, ws, owner]) })); + const r = res(); + await createRefreshEditGrantHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r + ); + expect(gateEpochCache.refreshEditGrantEpoch).toHaveBeenCalledWith("doc-1"); + expect(staleGp.disconnect).toHaveBeenCalledWith(true); + expect(freshGp.disconnect).not.toHaveBeenCalled(); + expect(ws.disconnect).not.toHaveBeenCalled(); + expect(owner.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, editGrantEpoch: 6 }); + }); + + it("sweeps EVERY non-terminated session room plus the caller's own (doc-wide)", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(6); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([ + { sessionDid: "s-a" }, + { sessionDid: "s-b" }, + ]); + const gpA: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const gpCaller: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const byRoom: Record = { + [getRoomName("doc-1", "s-a")]: [gpA], + [getRoomName("doc-1", "s-b")]: [], + [getRoomName("doc-1", "s-c")]: [gpCaller], // caller's sessionDid, NOT in the non-terminated list + }; + io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); + const r = res(); + await createRefreshEditGrantHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-c" } } as any, r + ); + expect(gpA.disconnect).toHaveBeenCalledWith(true); // a listed session's room was swept + expect(gpCaller.disconnect).toHaveBeenCalledWith(true); // the caller's own room was swept too + expect(io.in).toHaveBeenCalledWith(getRoomName("doc-1", "s-a")); + expect(io.in).toHaveBeenCalledWith(getRoomName("doc-1", "s-c")); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("does NOT force-drop when the gate epoch is unavailable (null) — the chokepoint backstop covers it", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(null); + const gp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([gp]) })); + const r = res(); + await createRefreshEditGrantHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r + ); + expect(gp.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, editGrantEpoch: null }); + }); +}); + +describe("GET /documents/:id/mirror", () => { + it("returns the latest mirror snapshot", async () => { + const deps: any = { mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }) } }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.getLatestMirror).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }); + }); + + it("404s when there is no mirror yet", async () => { + const deps: any = { mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue(null) } }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(r.status).toHaveBeenCalledWith(404); + }); +}); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index ad2d439..015c4c5 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -47,6 +47,7 @@ describe("handleAuth", () => { verifyOwnerToken: vi.fn(), verifyCollaborationToken: vi.fn(), verifyIdentityToken: vi.fn(), + verifyEditUcan: vi.fn(), getServerDid: vi.fn(), }; const fakeSessionManager = { @@ -57,14 +58,20 @@ describe("handleAuth", () => { updateRoomInfo: vi.fn(), addClientToSession: vi.fn(), getCollabJoinEnabled: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), }; const fakeMongoDBStore = {} as any; + const fakeGateEpochCache = { + getEditGrantEpoch: vi.fn(), + refreshEditGrantEpoch: vi.fn(), + }; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + gateEpochCache: fakeGateEpochCache as any, }; beforeEach(() => { @@ -419,6 +426,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(existingSession); fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); @@ -876,6 +884,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(existingSession); fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); @@ -1048,3 +1057,147 @@ describe("handleAuth", () => { }); }); +describe("handleAuth — edit-claim admission (existing session, non-owner)", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn(), + verifyCollaborationToken: vi.fn(), + verifyIdentityToken: vi.fn(), + verifyEditUcan: vi.fn(), + getServerDid: vi.fn(), + }; + const fakeSessionManager = { + getSession: vi.fn(), + getOtherNonTerminatedSessions: vi.fn(), + terminateSession: vi.fn(), + createSession: vi.fn(), + updateRoomInfo: vi.fn(), + addClientToSession: vi.fn(), + getCollabJoinEnabled: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), + fillOwnerIdentityDidIfAbsent: vi.fn(), + }; + const fakeMongoDBStore = {} as any; + const fakeGateEpochCache = { + getEditGrantEpoch: vi.fn(), + refreshEditGrantEpoch: vi.fn(), + }; + + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore, + gateEpochCache: fakeGateEpochCache as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseArgs = (over: Partial = {}): AuthArgs => ({ + documentId: "doc-1", + sessionDid: "sess-1", + collaborationToken: "ct", + appType: "ddoc", + ...over, + }); + + function existingSessionSetup() { + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "sess-1", + ownerDid: "owner-did", + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(true); + } + + it("admits a GP editor with a valid, fresh editUcan and records rail+epoch+actor", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 5, nullifier: "null-1" }); + (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gate-ucan" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); + expect(socket.data.rail).toBe("gp"); + expect(socket.data.admittedEditGrantEpoch).toBe(5); + expect(socket.data.actorHandle).toBe("null-1"); + }); + + it("REJECTS a stale-epoch GP editUcan — no fall-through to public even when join is enabled", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 2, nullifier: "null-1" }); + (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); // advanced past 2 + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); // public IS on — must NOT rescue a stale GP grant + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "stale" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); + expect(socket.data.rail).toBeUndefined(); // did not fall through to rail=public + }); + + it("admits a valid GP editUcan when the gate epoch is unreachable (getEditGrantEpoch → null) — transient fail-open", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 3, nullifier: "null-1" }); + (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(null); // gate transiently down + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gate-ucan" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); + expect(socket.data.rail).toBe("gp"); + expect(socket.data.admittedEditGrantEpoch).toBe(3); + }); + + it("REJECTS an invalid editUcan (verifyEditUcan → null) without falling through", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue(null); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "forged" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); + expect(socket.data.rail).toBeUndefined(); + }); + + it("admits a workspace editor (member proof + tier enabled) with rail=workspace", async () => { + existingSessionSetup(); + fakeAuthService.verifyOwnerToken.mockResolvedValue("member-did"); // != session ownerDid ⇒ editor + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth( + deps, + createFakeIO(), + socket, + baseArgs({ + ownerToken: "ot", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + }), + cb + ); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("workspace"); + }); + + it("public bearer is admitted as rail=public ONLY on collabJoinEnabled === true", async () => { + existingSessionSetup(); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs(), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("public"); + }); + + it("REJECTS a public bearer when collabJoinEnabled === undefined (legacy hardening — was open)", async () => { + existingSessionSetup(); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs(), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); + expect(socket.data.rail).toBeUndefined(); + }); +}); + diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts index 245037f..faf8249 100644 --- a/src/tests/services/socket-handlers.handleCommitHistory.test.ts +++ b/src/tests/services/socket-handlers.handleCommitHistory.test.ts @@ -43,6 +43,7 @@ describe("commitHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, + gateEpochCache: {} as any, }; it("returns early when not authenticated", async () => { diff --git a/src/tests/services/socket-handlers.handleDisconnecting.test.ts b/src/tests/services/socket-handlers.handleDisconnecting.test.ts index 4310886..fd1eb2b 100644 --- a/src/tests/services/socket-handlers.handleDisconnecting.test.ts +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -46,6 +46,7 @@ describe("handleDisconnecting", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + gateEpochCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts index 735d4ab..fa748b6 100644 --- a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts @@ -41,6 +41,7 @@ describe("handleDocumentCommit", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, + gateEpochCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index 5c7b12d..3a4685e 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -39,6 +39,8 @@ describe("handleDocumentUpdate", () => { }; const fakeSessionManager = { getRuntimeSession: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), + getCollabJoinEnabled: vi.fn(), }; const fakeMongoDBStore = { createUpdate: vi.fn(), @@ -48,6 +50,7 @@ describe("handleDocumentUpdate", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, + gateEpochCache: { getEditGrantEpoch: vi.fn() } as any, }; beforeEach(() => { @@ -306,5 +309,86 @@ describe("handleDocumentUpdate", () => { errorCode: ErrorCode.INTERNAL_ERROR, }); }); + + describe("handleDocumentUpdate — rail write-guard", () => { + function makeEditor( + railData: { rail: "gp" | "workspace" | "public"; admittedEditGrantEpoch?: number }, + broadcast?: { emit: ReturnType } + ) { + const socket = createFakeSocket(broadcast ?? { emit: vi.fn() }, { role: "editor" }); + socket.data.rail = railData.rail; + if (railData.admittedEditGrantEpoch !== undefined) { + socket.data.admittedEditGrantEpoch = railData.admittedEditGrantEpoch; + } + fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + return socket; + } + const args = { documentId: "doc-1", data: "d", collaborationToken: "ct" } as DocumentUpdateArgs; + + it("403 EDIT_REVOKED when a GP editor's admitted epoch is now stale — no persist, no broadcast", async () => { + const bcast = { emit: vi.fn() }; + const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 2 }, bcast); + (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(5); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); + expect(bcast.emit).not.toHaveBeenCalled(); + }); + + it("403 when a workspace editor's tier is now disabled", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.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(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("403 when a public editor's collabJoinEnabled is no longer true (false OR undefined)", async () => { + for (const val of [false, undefined]) { + vi.clearAllMocks(); + const socket = makeEditor({ rail: "public" }); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(val); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); + } + }); + + it("persists for a GP editor whose epoch is still current", async () => { + const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 5 }); + (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(5); + fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + + it("persists for a GP editor when the gate epoch is unreachable (null — fail-open)", async () => { + const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 2 }); + (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(null); + fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + + it("does NOT guard a dsheet editor (no rail model — must not 403)", async () => { + const socket = createFakeSocket({ emit: vi.fn() }, { role: "editor" }); + socket.data.appType = "dsheet"; // excluded from the ddoc-only guard + 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 }); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + }); }); diff --git a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts new file mode 100644 index 0000000..9bc1bef --- /dev/null +++ b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleMirrorSnapshot } from "../../services/socket-handlers"; +import { ErrorCode } from "../../types"; + +function socket(data: any) { + return { id: "s1", data: { authenticated: true, documentId: "doc-1", sessionDid: "sess-1", ...data } } as any; +} + +describe("handleMirrorSnapshot", () => { + let deps: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + sessionManager: { getRuntimeSession: vi.fn().mockResolvedValue({ sessionDid: "sess-1" }) }, + mongodbStore: { upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined) }, + }; + }); + + it("401s when not authenticated", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ authenticated: false, role: "editor", rail: "gp" }), { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401 })); + }); + + it("403s an authenticated socket that is neither owner nor an admitted rail editor", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "editor" }), { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("400s on a missing/invalid fileKeyEpoch", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct" } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400 })); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("400s (no persist) on an out-of-range fileKeyEpoch that would shadow the keep-latest read", async () => { + for (const bad of [1e21, Number.MAX_SAFE_INTEGER, 1_000_001, NaN, Infinity, 1.5]) { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: bad } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400 })); + } + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("persists for an admitted GP editor", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "editor", rail: "gp" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ documentId: "doc-1", data: "ct", fileKeyEpoch: 2, sessionDid: "sess-1", createdAt: expect.any(Number) }) + ); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + + it("persists for the owner", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); +}); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index ebc7970..a62c09b 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -15,6 +15,7 @@ describe("handleSetDocumentMeta", () => { authService: {} as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { upsertDocumentMeta } as any, + gateEpochCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts index 86878b5..0a1d43c 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -23,6 +23,7 @@ describe("handleSnapshot", () => { authService: { verifyCollaborationToken } as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { createSnapshot } as any, + gateEpochCache: {} as any, }; it("rejects a non-owner with 403", async () => { diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index 7efcc0f..da90c39 100644 --- a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -40,6 +40,7 @@ describe("updateHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, + gateEpochCache: {} as any, }; it("returns early when not authenticated", async () => { diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index a6730b8..a94ad6b 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -49,6 +49,7 @@ describe("handleTerminateSession", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + gateEpochCache: {} as any, }; beforeEach(() => { diff --git a/src/types/index.ts b/src/types/index.ts index 515511b..b2268a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,7 @@ export enum ErrorCode { NOT_AUTHENTICATED = "NOT_AUTHENTICATED", APP_MISMATCH = "APP_MISMATCH", JOIN_DISABLED = "JOIN_DISABLED", + EDIT_REVOKED = "EDIT_REVOKED", DB_ERROR = "DB_ERROR", INTERNAL_ERROR = "INTERNAL_ERROR", } @@ -113,6 +114,8 @@ export interface AuthArgs { ownerIdentityDid?: string; identityToken?: string; identityContractAddress?: string; + editUcan?: string; + actorHandle?: string; } export interface AuthResponseData { @@ -147,6 +150,12 @@ export interface SnapshotArgs { floorSeq: number; } +export interface MirrorSnapshotArgs { + documentId?: string; + data: string; + fileKeyEpoch: number; +} + export interface DocumentMetaArgs { documentId?: string; editLock: string | null; @@ -277,6 +286,7 @@ export interface ClientToServerEvents { "/documents/commit/history": ClientEventHandler; "/documents/update/history": ClientEventHandler; "/documents/snapshot": ClientEventHandler; + "/documents/mirror-snapshot": ClientEventHandler; "/documents/meta": ClientEventHandler; "/documents/peers/list": ClientEventHandler; "/documents/awareness": ClientEventHandler; @@ -302,6 +312,9 @@ export interface SocketData { role: "owner" | "editor"; authenticated: boolean; appType: AppType; + rail?: "gp" | "workspace" | "public"; + admittedEditGrantEpoch?: number; + actorHandle?: string; } // *************************************** @@ -352,6 +365,10 @@ export interface ServerConfig { serverDid: string; serverKeyPair?: any; }; + gate: { + url: string | undefined; + did: string | undefined; + }; rateLimit: { windowMs: number; max: number; From 8d2c7d2985e9219bf0c848e0f05c879c5fd46e2b Mon Sep 17 00:00:00 2001 From: Nadeem Date: Thu, 16 Jul 2026 00:10:27 +0530 Subject: [PATCH 02/11] feat: per-actor edit-bound admission + targeted eviction - verifyEditUcan returns a discriminated actor|legacy union - EditBoundCache: fail-closed per-actor (editHandle) admission poll against the gate /edit-bound endpoint, with TTL and stale-bound degrade while the gate is unreachable - enforce admission at the join, write, and mirror chokepoints - POST /documents/:id/evict-edit-actors for targeted cache eviction --- src/index.ts | 7 ++- src/services/auth.ts | 22 ++++--- src/services/gate-epoch.ts | 68 ++++++++++++++++++++++ src/services/owner-op-routes.ts | 36 +++++++++++- src/services/socket-handlers.deps.ts | 3 +- src/services/socket-handlers.ts | 85 ++++++++++++++++++++-------- src/types/index.ts | 1 + 7 files changed, 185 insertions(+), 37 deletions(-) diff --git a/src/index.ts b/src/index.ts index e67e03b..6036da3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,11 +17,12 @@ import { createCollabJoinEnabledHandler, createWorkspaceEditTierHandler, createRefreshEditGrantHandler, + createEvictEditActorsHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createMirrorReadHandler, } from "./services/owner-op-routes"; -import { gateEpochCache } from "./services/gate-epoch"; +import { gateEpochCache, editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; @@ -107,6 +108,10 @@ class CollaborationServer { "/documents/:documentId/refresh-edit-grant", createRefreshEditGrantHandler({ authService, sessionManager, gateEpochCache }, this.io) ); + this.app.post( + "/documents/:documentId/evict-edit-actors", + createEvictEditActorsHandler({ authService, sessionManager, editBoundCache }) + ); this.app.get( "/documents/:documentId/mirror", createMirrorReadHandler({ mongodbStore }) diff --git a/src/services/auth.ts b/src/services/auth.ts index c1228c9..902734c 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -80,12 +80,17 @@ export class AuthService { /** * Verify a gate-minted edit-admission UCAN, rooted at the pinned gate DID. Returns the - * signed facts (editGrantEpoch, nullifier) or null. The epoch comes from the token, never a client arg. + * signed facts — actor UCANs carry `editHandle`, legacy UCANs carry `editGrantEpoch`+`nullifier` — + * or null. These come from the token, never a client arg. */ async verifyEditUcan( token: string, documentId: string - ): Promise<{ editGrantEpoch: number; nullifier: string } | null> { + ): Promise< + | { kind: "legacy"; editGrantEpoch: number; nullifier: string } + | { kind: "actor"; editHandle: string } + | null + > { if (!this.gateDid) return null; // GP editing disabled until GATE_DID is pinned try { const result = await ucans.verify(token, { @@ -107,15 +112,14 @@ export class AuthService { docId?: string; editGrantEpoch?: number; nullifier?: string; + editHandle?: string; }; - if ( - fact.docId !== documentId || - typeof fact.editGrantEpoch !== "number" || - typeof fact.nullifier !== "string" - ) { - return null; + if (fact.docId !== documentId) return null; + if (typeof fact.editHandle === "string") return { kind: "actor", editHandle: fact.editHandle }; + if (typeof fact.editGrantEpoch === "number" && typeof fact.nullifier === "string") { + return { kind: "legacy", editGrantEpoch: fact.editGrantEpoch, nullifier: fact.nullifier }; } - return { editGrantEpoch: fact.editGrantEpoch, nullifier: fact.nullifier }; + return null; } catch (error) { console.error("Edit UCAN verification error:", error); return null; diff --git a/src/services/gate-epoch.ts b/src/services/gate-epoch.ts index 6436d38..2c6189b 100644 --- a/src/services/gate-epoch.ts +++ b/src/services/gate-epoch.ts @@ -56,3 +56,71 @@ export class GateEpochCache { } export const gateEpochCache = new GateEpochCache(); + +const EDIT_BOUND_TTL_MS = 10_000; // revocation-latency SLA; targeted evict() makes it near-instant + +type BoundState = "bound" | "unbound" | "stale-bound" | "cold"; + +/** + * Per-actor fail-closed cache of gate edit-binding (see docs/architecture/gp-semaphore.md). + * Same expiry-on-read + single-flight shape as GateEpochCache, but keyed per (docId, handle) + * and fail-closed on a cold/never-fetched entry instead of fail-open. + */ +export class EditBoundCache { + private cache = new Map(); + private inflight = new Map>(); + + constructor(private gateUrl: string | undefined = config.gate.url) {} + + private key(docId: string, editHandle: string): string { + return `${docId} ${editHandle}`; + } + + private async rawFetch(docId: string, editHandle: string): Promise { + if (!this.gateUrl) return null; + try { + const res = await fetch(`${this.gateUrl}/gate/doc/${docId}/edit-bound`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handles: [editHandle] }), + }); + if (!res.ok) return null; + const body = (await res.json()) as { bound?: string[] }; + const bound = Array.isArray(body.bound) && body.bound.includes(editHandle); + this.cache.set(this.key(docId, editHandle), { bound, fetchedAt: Date.now() }); + return bound; + } catch (error) { + console.error("edit-bound fetch error:", error); + return null; + } + } + + private fetch(docId: string, editHandle: string): Promise { + const k = this.key(docId, editHandle); + const existing = this.inflight.get(k); + if (existing) return existing; // single-flight: coalesce concurrent misses + const p = this.rawFetch(docId, editHandle).finally(() => this.inflight.delete(k)); + this.inflight.set(k, p); + return p; + } + + /** Cached read (≤TTL, expiry-on-read); fetches on miss/expiry; fail-closed on gate error. */ + async check(docId: string, editHandle: string): Promise { + const hit = this.cache.get(this.key(docId, editHandle)); + if (hit && Date.now() - hit.fetchedAt < EDIT_BOUND_TTL_MS) return hit.bound ? "bound" : "unbound"; + const fresh = await this.fetch(docId, editHandle); + if (fresh !== null) return fresh ? "bound" : "unbound"; + // Gate unreachable: serve last-known-good BOUND past its TTL (permissions can't change + // while the gate is down — a demote itself needs the gate). Never serve stale unbound, + // never serve cold as admitted. + if (hit?.bound) return "stale-bound"; + return "cold"; + } + + /** Targeted revocation: drop specific (docId, handle) entries so the next check() re-fetches. */ + evict(docId: string, handles: string[]): void { + for (const editHandle of handles) this.cache.delete(this.key(docId, editHandle)); + } +} + +export const editBoundCache = new EditBoundCache(); diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 1739f7a..ec7b96d 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -3,7 +3,7 @@ import type { AppServer } from "../types/index"; import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; -import type { GateEpochCache } from "./gate-epoch"; +import type { GateEpochCache, EditBoundCache } from "./gate-epoch"; import { getRoomName } from "./socket-handlers"; import { Hex } from "viem"; @@ -154,7 +154,7 @@ export function createRefreshEditGrantHandler( for (const sd of sessionDids) { const sockets = await io.in(getRoomName(documentId, sd)).fetchSockets(); for (const s of sockets) { - if (s.data.rail === "gp" && (s.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { + if (s.data.railKind === "gp-legacy" && (s.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { s.disconnect(true); } } @@ -165,6 +165,38 @@ export function createRefreshEditGrantHandler( }; } +/** Targeted eviction of specific gp-actor handles from the per-actor edit-bound cache. + * No socket disconnect: enforcement is the next-write/mirror 403 at the existing + * socket chokepoints (see docs/architecture/gp-semaphore.md), so co-editors are undisturbed. */ +export function createEvictEditActorsHandler(deps: OwnerOpDeps & { editBoundCache: EditBoundCache }) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, handles, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, identityContractAddress: identityContractAddress as Hex, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; + deps.editBoundCache.evict(documentId, list); + res.status(200).json({ ok: true, evicted: list.length }); + }; +} + export interface ListMyDocumentsDeps { authService: Pick; mongodbStore: Pick; diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts index 37c4506..57a6dc4 100644 --- a/src/services/socket-handlers.deps.ts +++ b/src/services/socket-handlers.deps.ts @@ -1,11 +1,12 @@ import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; -import type { GateEpochCache } from "./gate-epoch"; +import type { GateEpochCache, EditBoundCache } from "./gate-epoch"; export interface SocketHandlerDeps { authService: AuthService; sessionManager: SessionManager; mongodbStore: MongoDBStore; gateEpochCache: GateEpochCache; + editBoundCache: EditBoundCache; } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index dccd99b..a6aa0e6 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -26,7 +26,7 @@ import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; import { mongodbStore } from "./mongodb-store"; import { sessionManager } from "./session-manager"; -import { gateEpochCache } from "./gate-epoch"; +import { gateEpochCache, editBoundCache } from "./gate-epoch"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; @@ -35,6 +35,7 @@ const defaultDeps: SocketHandlerDeps = { sessionManager, mongodbStore, gateEpochCache, + editBoundCache, }; function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { @@ -153,6 +154,7 @@ export async function handleAuth( let roomInfo: string | undefined; let resolvedAppType: AppType; let rail: "gp" | "workspace" | "public" | undefined = undefined; + let railKind: "gp-actor" | "gp-legacy" | "workspace" | "public" | undefined = undefined; let admittedEditGrantEpoch: number | undefined = undefined; let actorHandle: string | undefined = undefined; @@ -352,11 +354,38 @@ export async function handleAuth( if (storedAppType === "ddoc" && role === "editor") { if (args.editUcan) { const gp = await authService.verifyEditUcan(args.editUcan, documentId); - const currentEpoch = gp ? await deps.gateEpochCache.getEditGrantEpoch(documentId) : null; - if (gp && (currentEpoch === null || gp.editGrantEpoch >= currentEpoch)) { - rail = "gp"; - admittedEditGrantEpoch = gp.editGrantEpoch; - actorHandle = gp.nullifier; + if (gp?.kind === "actor") { + // Per-actor positive-state admission (keyed on editHandle = hash(C, docId)); fail-closed on + // cold/unbound. See docs/architecture/gp-semaphore.md. + const state = await deps.editBoundCache.check(documentId, gp.editHandle); + if (state === "bound" || state === "stale-bound") { + rail = "gp"; + railKind = "gp-actor"; + actorHandle = gp.editHandle; + } else { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + } else if (gp?.kind === "legacy") { + // Legacy doc-wide epoch admit (dual-support until 7-day UCAN expiry + full client rollout). + const currentEpoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); + if (currentEpoch === null || gp.editGrantEpoch >= currentEpoch) { + rail = "gp"; + railKind = "gp-legacy"; + admittedEditGrantEpoch = gp.editGrantEpoch; + actorHandle = gp.nullifier; + } else { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } } else { return callback({ status: false, @@ -373,6 +402,7 @@ export async function handleAuth( (await sessionManager.getWorkspaceEditEnabled(documentId, existingSession.sessionDid)) === true ) { rail = "workspace"; + railKind = "workspace"; actorHandle = args.ownerAddress?.toLowerCase(); } else { // Read fresh from Mongo (an HTTP process can flip this; the in-memory copy may be @@ -387,6 +417,7 @@ export async function handleAuth( }); } rail = "public"; + railKind = "public"; actorHandle = args.actorHandle; } } @@ -419,6 +450,7 @@ export async function handleAuth( socket.data.role = role; socket.data.appType = resolvedAppType; socket.data.rail = rail; + socket.data.railKind = railKind; socket.data.admittedEditGrantEpoch = admittedEditGrantEpoch; socket.data.actorHandle = actorHandle; @@ -460,6 +492,24 @@ export async function handleAuth( } } +// Live per-actor edit-admission re-check against the ADMITTED context stamped at JOIN. +// Fail-closed for the actor rail (cold/unbound → reject). See docs/architecture/gp-semaphore.md. +export async function isStillAdmitted(socket: AppSocket, deps: SocketHandlerDeps): Promise { + const { rail, railKind, documentId, sessionDid, actorHandle, admittedEditGrantEpoch } = socket.data; + if (!documentId || !sessionDid) return false; + if (railKind === "gp-actor") { + if (!actorHandle) return false; + const s = await deps.editBoundCache.check(documentId, actorHandle); + return s === "bound" || s === "stale-bound"; + } + if (railKind === "gp-legacy") { + const epoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); + return epoch === null || (admittedEditGrantEpoch ?? -1) >= epoch; + } + if (rail === "workspace") return (await deps.sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) === true; + return (await deps.sessionManager.getCollabJoinEnabled(documentId, sessionDid)) === true; +} + export async function handleDocumentUpdate( deps: SocketHandlerDeps, io: AppServer, @@ -518,24 +568,7 @@ export async function handleDocumentUpdate( } if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { - const rail = socket.data.rail; - let revoked = false; - if (rail === "gp") { - const currentEpoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); - if (currentEpoch !== null && (socket.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { - revoked = true; - } - } else if (rail === "workspace") { - if ((await sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) !== true) { - revoked = true; - } - } else { - // public / no rail: no epoch to check; only an explicit true still authorizes. - // false or a legacy-undefined flag both revoke, matching admission. - if ((await sessionManager.getCollabJoinEnabled(documentId, sessionDid)) !== true) { - revoked = true; - } - } + const revoked = !(await isStillAdmitted(socket, deps)); if (revoked) { return callback({ status: false, @@ -915,6 +948,10 @@ export async function handleMirrorSnapshot( if (socket.data.role !== "owner" && !socket.data.rail) { return callback({ status: false, statusCode: 403, error: "Not an editor", errorCode: ErrorCode.COMMIT_UNAUTHORIZED }); } + // Live per-actor re-check: a demoted editor holding a stale socket must not author a mirror. + if (socket.data.role !== "owner" && !(await isStillAdmitted(socket, deps))) { + return callback({ status: false, statusCode: 403, error: "Edit access has been revoked", errorCode: ErrorCode.EDIT_REVOKED }); + } const { data, fileKeyEpoch } = args; const documentId = args.documentId || socket.data.documentId; diff --git a/src/types/index.ts b/src/types/index.ts index b2268a7..69c8bc3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -313,6 +313,7 @@ export interface SocketData { authenticated: boolean; appType: AppType; rail?: "gp" | "workspace" | "public"; + railKind?: "gp-actor" | "gp-legacy" | "workspace" | "public"; admittedEditGrantEpoch?: number; actorHandle?: string; } From 8d5b8a5326aae070bed07acdfa3a580c7406adef Mon Sep 17 00:00:00 2001 From: Nadeem Date: Thu, 16 Jul 2026 11:35:14 +0530 Subject: [PATCH 03/11] feat(workspace-edit): joinOnly semantics, identity-based roles, matching-DID workspace arm, {ok,updated} tier route Phase 1 server side of whole-team PrivateEdit: - joinOnly connections never create/bind a session (404 ROOM_NOT_ESTABLISHED), role capped at editor, null-fill heal skipped - identity-based role determination on bound ddoc sessions: proven identityToken DID vs bound ownerIdentityDid decides owner/editor; invalid token = 401; token-less joins keep legacy compare behind LEGACY_ROLE_FALLBACK (default on) - workspace arm admission replaced: requires ownerToken DID MATCHING the session's ownerDid + workspaceEditEnabled === true (closes cross-portal hole) - workspace-edit-tier route returns {ok, updated} row count - test-mock repairs for the pre-existing editBoundCache/kind stale-mock class Co-Authored-By: Claude Fable 5 --- src/config/index.ts | 5 + src/services/owner-op-routes.ts | 5 +- src/services/session-manager.ts | 6 +- src/services/socket-handlers.ts | 65 ++++- src/tests/services/auth.test.ts | 2 +- src/tests/services/owner-op-routes.test.ts | 40 ++- .../socket-handlers.handleAuth.test.ts | 237 +++++++++++++++++- ...ocket-handlers.handleCommitHistory.test.ts | 1 + ...ocket-handlers.handleDisconnecting.test.ts | 1 + ...cket-handlers.handleDocumentCommit.test.ts | 1 + ...cket-handlers.handleDocumentUpdate.test.ts | 2 + ...cket-handlers.handleMirrorSnapshot.test.ts | 3 +- ...ket-handlers.handleSetDocumentMeta.test.ts | 1 + .../socket-handlers.handleSnapshot.test.ts | 1 + ...ocket-handlers.handleUpdateHistory.test.ts | 1 + .../socket-handlers.terminateSession.test.ts | 1 + src/types/index.ts | 5 + 17 files changed, 357 insertions(+), 20 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 82106af..9b49213 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -24,6 +24,11 @@ export const config: ServerConfig = { auth: { serverDid: process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + // Sunset flag: while true, a token-less join on a bound + // session keeps the legacy ownerDid-compare role (old clients stay owners). Set + // LEGACY_ROLE_FALLBACK=false once identityToken-sending clients are fully rolled + // out — from then on a token-less join on a bound session is never owner. + legacyRoleFallback: process.env.LEGACY_ROLE_FALLBACK !== "false", }, rateLimit: { diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index ec7b96d..bff83d4 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -96,8 +96,9 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); const sessionDids = new Set(targets.map((t) => t.sessionDid)); sessionDids.add(sessionDid); + let updated = 0; for (const sd of sessionDids) { - await deps.sessionManager.setWorkspaceEditEnabled(documentId, sd, !!enabled); + if (await deps.sessionManager.setWorkspaceEditEnabled(documentId, sd, !!enabled)) updated++; } if (enabled === false) { @@ -112,7 +113,7 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) } } - res.status(200).json({ ok: true }); + res.status(200).json({ ok: true, updated }); }; } diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 3e2a284..9674c12 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -165,14 +165,16 @@ export class SessionManager { return (doc as any)?.workspaceEditEnabled; } - async setWorkspaceEditEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + async setWorkspaceEditEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { const sessionKey = this.getSessionKey(documentId, sessionDid); const session = this.inMemorySessions.get(sessionKey); if (session) session.workspaceEditEnabled = enabled; try { - await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { workspaceEditEnabled: enabled }); + const res = await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { workspaceEditEnabled: enabled }); + return res !== null || session !== undefined; } catch (error) { console.error("Error setting workspaceEditEnabled:", error); + return false; } } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index a6aa0e6..d003847 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -29,6 +29,7 @@ import { sessionManager } from "./session-manager"; import { gateEpochCache, editBoundCache } from "./gate-epoch"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; +import { config } from "../config"; const defaultDeps: SocketHandlerDeps = { authService, @@ -158,6 +159,19 @@ export async function handleAuth( let admittedEditGrantEpoch: number | undefined = undefined; let actorHandle: string | undefined = undefined; + // 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 + // session and bind THEIR identity as the room's root of trust (member-first-bind). + // Distinct code so clients can render read-only-until-creator-opens. + if (!existingSession && args.joinOnly === true) { + return callback({ + status: false, + statusCode: 404, + error: "Room not established", + errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, + }); + } + if (!existingSession && args.ownerToken) { // - Set up a new session (owner flow) if (!args.ownerToken || !args.sessionDid) { @@ -320,7 +334,47 @@ export async function handleAuth( ); } - role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + // Identity-based role (ddoc-only): 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, never a fallback; a token-less join keeps the legacy compare only while + // the sunset flag is on. + const boundIdentityDid = existingSession.ownerIdentityDid ?? null; + if (storedAppType === "ddoc" && boundIdentityDid && args.identityToken) { + const provenDid = args.identityContractAddress + ? await authService.verifyIdentityToken( + args.identityToken, + args.identityContractAddress as Hex, + documentId + ) + : null; + if (!provenDid) { + return callback({ + status: false, + statusCode: 401, + error: "Invalid identity proof", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + } + role = + provenDid === boundIdentityDid && ownerDid === existingSession.ownerDid + ? "owner" + : "editor"; + } else if ( + storedAppType === "ddoc" && + boundIdentityDid && + !args.identityToken && + !config.auth.legacyRoleFallback + ) { + role = "editor"; + } else { + role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + } + + // joinOnly caps the role: on an unbound/legacy session the shared-DID compare + // 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 // bound empty) is filled — once, atomically — by a proven owner, so the pre-fix @@ -328,6 +382,7 @@ export async function handleAuth( if ( storedAppType === "ddoc" && role === "owner" && + args.joinOnly !== true && !existingSession.ownerIdentityDid && args.identityToken && args.identityContractAddress @@ -395,10 +450,12 @@ export async function handleAuth( }); } } else if ( - // Gates a collaborator whose portal DID differs from the doc owner's. A team member - // sharing the portal's collaborator DID resolves as owner above and never reaches here, - // so the tier is not a per-member edit boundary for shared-DID teams (awaits MemberSA). + // Whole-team PrivateEdit: membership proof is the SHARED portal secret — the + // ownerToken must resolve to the session's own ownerDid (identity already told + // us this bearer is not the creator). A valid-but-DIFFERENT ownerToken is some + // other portal's owner and gets no workspace admission. ownerDid && + ownerDid === existingSession.ownerDid && (await sessionManager.getWorkspaceEditEnabled(documentId, existingSession.sessionDid)) === true ) { rail = "workspace"; diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 56ace4f..7283fdf 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -128,7 +128,7 @@ describe("verifyEditUcan", () => { (ucans.verify as any).mockResolvedValue({ ok: true }); (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editGrantEpoch: 3, nullifier: "n-1" }] } }); const svc = new AuthService(serverDid, gateDid); - expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ editGrantEpoch: 3, nullifier: "n-1" }); + expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "legacy", editGrantEpoch: 3, nullifier: "n-1" }); // rooted at the pinned gate DID + the collab:EDIT capability expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ audience: serverDid, diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 374a1de..06b82af 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -146,6 +146,36 @@ describe("POST workspace-edit-tier", () => { expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-b", true); expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-req", true); }); + + it("returns {ok, updated} counting the sessions actually flipped", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s-a" }]); + deps.sessionManager.setWorkspaceEditEnabled.mockResolvedValue(true); + const r = res(); + + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, updated: 2 }); + }); + + it("reports updated: 0 when no session row is actually flipped", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([]); + deps.sessionManager.setWorkspaceEditEnabled.mockResolvedValue(false); + const r = res(); + + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, updated: 0 }); + }); }); describe("POST /list-my-documents", () => { @@ -271,8 +301,8 @@ describe("POST refresh-edit-grant", () => { it("refreshes the epoch and force-drops ONLY stale GP sockets (not fresh GP / workspace / owner)", async () => { deps.authService.verifyOwnerOp.mockResolvedValue(true); gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(6); - const staleGp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - const freshGp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 6 }, disconnect: vi.fn() }; + const staleGp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const freshGp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 6 }, disconnect: vi.fn() }; const ws: any = { data: { role: "editor", rail: "workspace", admittedEditGrantEpoch: 0 }, disconnect: vi.fn() }; const owner: any = { data: { role: "owner" }, disconnect: vi.fn() }; io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([staleGp, freshGp, ws, owner]) })); @@ -296,8 +326,8 @@ describe("POST refresh-edit-grant", () => { { sessionDid: "s-a" }, { sessionDid: "s-b" }, ]); - const gpA: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - const gpCaller: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const gpA: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const gpCaller: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; const byRoom: Record = { [getRoomName("doc-1", "s-a")]: [gpA], [getRoomName("doc-1", "s-b")]: [], @@ -318,7 +348,7 @@ describe("POST refresh-edit-grant", () => { it("does NOT force-drop when the gate epoch is unavailable (null) — the chokepoint backstop covers it", async () => { deps.authService.verifyOwnerOp.mockResolvedValue(true); gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(null); - const gp: any = { data: { role: "editor", rail: "gp", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; + const gp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([gp]) })); const r = res(); await createRefreshEditGrantHandler(deps, io)( diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index 015c4c5..41a2110 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleAuth, getRoomName } from "../../services/socket-handlers"; import { AppServer, AppSocket, AuthArgs, ErrorCode } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import { config } from "../../config"; function createFakeIO(options?: { broadcastOperator?: { emit: ReturnType }; @@ -72,6 +73,7 @@ describe("handleAuth", () => { sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, gateEpochCache: fakeGateEpochCache as any, + editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { @@ -1055,6 +1057,216 @@ describe("handleAuth", () => { ); expect(fakeSocket.data.role).toBe("owner"); }); + + it("joinOnly: rejects ROOM_NOT_ESTABLISHED instead of creating a session", async () => { + fakeSessionManager.getSession.mockResolvedValue(null); + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + joinOnly: true, + }, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 404, + error: "Room not established", + errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, + }); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + }); + + it("joinOnly: caps role at editor and skips the null-fill heal on an unbound session", async () => { + fakeSessionManager.getSession.mockResolvedValue({ + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: null, + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "member-identity-token", + identityContractAddress: "0x3333333333333333333333333333333333333333", + joinOnly: true, + }, callback); + + expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + expect(fakeSocket.data.rail).toBe("workspace"); + }); + + const boundSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: "did:key:creator", + appType: "ddoc", + }; + const boundJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "identity-token", + identityContractAddress: "0x3333333333333333333333333333333333333333", + }; + + it("role: proven identity == bound + matching ownerToken → owner", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + ); + }); + + it("role: proven identity differs from bound → editor (workspace member)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) + ); + }); + + it("role: INVALID identity token on a bound session → 401, never a fallback", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue(null); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Invalid identity proof", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("role: token-less join on a bound session → legacy owner while fallback is on", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + ); + }); + + it("role: token-less join on a bound session → editor once the fallback is sunset", async () => { + const prior = config.auth.legacyRoleFallback; + config.auth.legacyRoleFallback = false; + try { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) + ); + } finally { + config.auth.legacyRoleFallback = prior; + } + }); + + it("workspace arm: rejects a FOREIGN portal's valid ownerToken (no cross-portal admission)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:OTHER-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:stranger"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("workspace arm: member is rejected fail-closed when the flag is undefined", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("workspace arm: member with matching ownerDid + flag true → rail and railKind workspace", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(fakeSocket.data.rail).toBe("workspace"); + expect(fakeSocket.data.railKind).toBe("workspace"); + }); }); describe("handleAuth — edit-claim admission (existing session, non-owner)", () => { @@ -1087,6 +1299,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, gateEpochCache: fakeGateEpochCache as any, + editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { @@ -1113,7 +1326,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () it("admits a GP editor with a valid, fresh editUcan and records rail+epoch+actor", async () => { existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 5, nullifier: "null-1" }); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 5, nullifier: "null-1" }); (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); const socket = createFakeSocket(); const cb = vi.fn(); @@ -1126,7 +1339,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () it("REJECTS a stale-epoch GP editUcan — no fall-through to public even when join is enabled", async () => { existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 2, nullifier: "null-1" }); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 2, nullifier: "null-1" }); (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); // advanced past 2 fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); // public IS on — must NOT rescue a stale GP grant const socket = createFakeSocket(); @@ -1138,7 +1351,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () it("admits a valid GP editUcan when the gate epoch is unreachable (getEditGrantEpoch → null) — transient fail-open", async () => { existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ editGrantEpoch: 3, nullifier: "null-1" }); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 3, nullifier: "null-1" }); (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(null); // gate transiently down const socket = createFakeSocket(); const cb = vi.fn(); @@ -1160,8 +1373,19 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () }); it("admits a workspace editor (member proof + tier enabled) with rail=workspace", async () => { - existingSessionSetup(); - fakeAuthService.verifyOwnerToken.mockResolvedValue("member-did"); // != session ownerDid ⇒ editor + // The shared portal secret (ownerToken) only proves portal membership; the per-person + // identity proof decides owner-vs-editor. Here identity resolves a non-creator DID, so + // this bearer is a workspace editor even though ownerToken matches the session's own ownerDid. + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "sess-1", + ownerDid: "owner-did", + ownerIdentityDid: "owner-identity-did", + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(true); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); // session's own ownerDid + fakeAuthService.verifyIdentityToken.mockResolvedValue("member-identity-did"); // != bound owner identity ⇒ editor fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); const socket = createFakeSocket(); const cb = vi.fn(); @@ -1173,11 +1397,14 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () ownerToken: "ot", ownerAddress: "0x1111111111111111111111111111111111111111", contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "it", + identityContractAddress: "0x3333333333333333333333333333333333333333", }), cb ); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); expect(socket.data.rail).toBe("workspace"); + expect(socket.data.railKind).toBe("workspace"); }); it("public bearer is admitted as rail=public ONLY on collabJoinEnabled === true", async () => { diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts index faf8249..0c3b226 100644 --- a/src/tests/services/socket-handlers.handleCommitHistory.test.ts +++ b/src/tests/services/socket-handlers.handleCommitHistory.test.ts @@ -44,6 +44,7 @@ describe("commitHistory", () => { sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, gateEpochCache: {} as any, + editBoundCache: {} as any, }; it("returns early when not authenticated", async () => { diff --git a/src/tests/services/socket-handlers.handleDisconnecting.test.ts b/src/tests/services/socket-handlers.handleDisconnecting.test.ts index fd1eb2b..421ac7d 100644 --- a/src/tests/services/socket-handlers.handleDisconnecting.test.ts +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -47,6 +47,7 @@ describe("handleDisconnecting", () => { sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, gateEpochCache: {} as any, + editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts index fa748b6..c9684c6 100644 --- a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts @@ -42,6 +42,7 @@ describe("handleDocumentCommit", () => { sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, gateEpochCache: {} as any, + editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index 3a4685e..ec73a85 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -51,6 +51,7 @@ describe("handleDocumentUpdate", () => { sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, gateEpochCache: { getEditGrantEpoch: vi.fn() } as any, + editBoundCache: {} as any, }; beforeEach(() => { @@ -317,6 +318,7 @@ describe("handleDocumentUpdate", () => { ) { const socket = createFakeSocket(broadcast ?? { emit: vi.fn() }, { role: "editor" }); socket.data.rail = railData.rail; + if (railData.rail === "gp") socket.data.railKind = "gp-legacy"; if (railData.admittedEditGrantEpoch !== undefined) { socket.data.admittedEditGrantEpoch = railData.admittedEditGrantEpoch; } diff --git a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts index 9bc1bef..dcc127e 100644 --- a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts @@ -13,6 +13,7 @@ describe("handleMirrorSnapshot", () => { deps = { sessionManager: { getRuntimeSession: vi.fn().mockResolvedValue({ sessionDid: "sess-1" }) }, mongodbStore: { upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined) }, + gateEpochCache: { getEditGrantEpoch: vi.fn().mockResolvedValue(null) }, }; }); @@ -47,7 +48,7 @@ describe("handleMirrorSnapshot", () => { it("persists for an admitted GP editor", async () => { const cb = vi.fn(); - await handleMirrorSnapshot(deps, socket({ role: "editor", rail: "gp" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + await handleMirrorSnapshot(deps, socket({ role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 1 }), { data: "ct", fileKeyEpoch: 2 } as any, cb); expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalledWith( expect.objectContaining({ documentId: "doc-1", data: "ct", fileKeyEpoch: 2, sessionDid: "sess-1", createdAt: expect.any(Number) }) ); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index a62c09b..3ab32f0 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -16,6 +16,7 @@ describe("handleSetDocumentMeta", () => { sessionManager: { getRuntimeSession } as any, mongodbStore: { upsertDocumentMeta } as any, gateEpochCache: {} as any, + editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts index 0a1d43c..60ff0f3 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -24,6 +24,7 @@ describe("handleSnapshot", () => { sessionManager: { getRuntimeSession } as any, mongodbStore: { createSnapshot } as any, gateEpochCache: {} as any, + editBoundCache: {} as any, }; it("rejects a non-owner with 403", async () => { diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index da90c39..de539ed 100644 --- a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -41,6 +41,7 @@ describe("updateHistory", () => { sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, gateEpochCache: {} as any, + editBoundCache: {} as any, }; it("returns early when not authenticated", async () => { diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index a94ad6b..96539ae 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -50,6 +50,7 @@ describe("handleTerminateSession", () => { sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, gateEpochCache: {} as any, + editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/types/index.ts b/src/types/index.ts index 69c8bc3..552f685 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -82,6 +82,7 @@ export enum ErrorCode { APP_MISMATCH = "APP_MISMATCH", JOIN_DISABLED = "JOIN_DISABLED", EDIT_REVOKED = "EDIT_REVOKED", + ROOM_NOT_ESTABLISHED = "ROOM_NOT_ESTABLISHED", DB_ERROR = "DB_ERROR", INTERNAL_ERROR = "INTERNAL_ERROR", } @@ -116,6 +117,9 @@ export interface AuthArgs { identityContractAddress?: string; editUcan?: string; actorHandle?: string; + /** Privilege-reducing join mode (workspace member): the server must never create or + * bind a session for this connection, and the role is capped at editor. */ + joinOnly?: boolean; } export interface AuthResponseData { @@ -365,6 +369,7 @@ export interface ServerConfig { auth: { serverDid: string; serverKeyPair?: any; + legacyRoleFallback: boolean; }; gate: { url: string | undefined; From e6ce985c6c6b5aa623757237a016665970b5657b Mon Sep 17 00:00:00 2001 From: Nadeem Date: Thu, 16 Jul 2026 18:05:36 +0530 Subject: [PATCH 04/11] minor fixes --- src/services/auth.ts | 8 +++++++- src/utils/contract.ts | Bin 6330 -> 6670 bytes 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/auth.ts b/src/services/auth.ts index 902734c..9dfd71f 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -21,7 +21,13 @@ export class AuthService { async verifyOwnerToken(token: string, contractAddress: Hex, collaboratorAddress: Hex) { try { const ownerDid = await getOwnerDid(contractAddress, collaboratorAddress); - if (!ownerDid) return null; + // Reject anything that isn't a DID string before it reaches ucans.verify + // (which throws a TypeError on a malformed rootIssuer instead of failing + // the verification) — an unregistered or mis-decoded collaborator must + // 401 cleanly. + if (!ownerDid || typeof ownerDid !== "string" || !ownerDid.startsWith("did:")) { + return null; + } const result = await ucans.verify(token, { audience: this.serverDid, diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 2c86b8e3ac954d03669187cc3b9ddf8ffcfa8ddf..531f1c9ea3fc9137ef60a675b9a28be4f59048cb 100644 GIT binary patch delta 364 zcmXw#Jx;?w5QQnAL89UUPZo;oB1Rx8PzoSTL;)2PR2%QaUTNcvX2yzK Date: Fri, 17 Jul 2026 03:57:43 +0530 Subject: [PATCH 05/11] add rotation support --- src/index.ts | 5 + src/services/auth.ts | 66 +++--- src/services/mongodb-store.ts | 8 + src/services/owner-op-routes.ts | 76 ++++++- src/services/session-manager.ts | 48 +++- src/services/socket-handlers.ts | 53 +++++ src/tests/services/auth.test.ts | 49 +++- src/tests/services/owner-op-routes.test.ts | 143 +++++++++++- src/tests/services/session-manager.test.ts | 95 +++++++- .../socket-handlers.handleAuth.test.ts | 213 +++++++++++++++++- ...ket-handlers.handleSetDocumentMeta.test.ts | 50 +++- .../socket-handlers.terminateSession.test.ts | 45 ++++ src/tests/utils/contract.test.ts | 54 +++++ src/types/index.ts | 11 + src/utils/contract.ts | Bin 6670 -> 7952 bytes 15 files changed, 880 insertions(+), 36 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6036da3..86ce139 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { createWorkspaceEditTierHandler, createRefreshEditGrantHandler, createEvictEditActorsHandler, + createEvictWorkspaceMemberHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createMirrorReadHandler, @@ -112,6 +113,10 @@ class CollaborationServer { "/documents/:documentId/evict-edit-actors", createEvictEditActorsHandler({ authService, sessionManager, editBoundCache }) ); + this.app.post( + "/workspaces/:portalAddress/evict-member", + createEvictWorkspaceMemberHandler({ authService, sessionManager }, this.io) + ); this.app.get( "/documents/:documentId/mirror", createMirrorReadHandler({ mongodbStore }) diff --git a/src/services/auth.ts b/src/services/auth.ts index 9dfd71f..bcd66d7 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,5 +1,5 @@ import * as ucans from "@ucans/ucans"; -import { getIdentitySigningDid, getOwnerDid } from "../utils/contract"; +import { getIdentitySigningDid, getOwnerDid, refreshOwnerDid } from "../utils/contract"; import { Hex } from "viem"; import NodeCache from "node-cache"; import { config } from "../config"; @@ -8,6 +8,9 @@ export class AuthService { private serverDid: string; private gateDid?: string; private collaborationTokenCache = new NodeCache({ stdTTL: 3600 }); + // Rate-limits the mismatch-triggered fresh chain read so garbage tokens + // can't hammer the RPC. + private ownerDidRecheckMemo = new NodeCache({ stdTTL: 10 }); constructor(serverDid: string, gateDid?: string) { this.serverDid = serverDid; @@ -20,38 +23,49 @@ export class AuthService { async verifyOwnerToken(token: string, contractAddress: Hex, collaboratorAddress: Hex) { try { - const ownerDid = await getOwnerDid(contractAddress, collaboratorAddress); - // Reject anything that isn't a DID string before it reaches ucans.verify - // (which throws a TypeError on a malformed rootIssuer instead of failing - // the verification) — an unregistered or mis-decoded collaborator must - // 401 cleanly. - if (!ownerDid || typeof ownerDid !== "string" || !ownerDid.startsWith("did:")) { - return null; + const cachedDid = await getOwnerDid(contractAddress, collaboratorAddress); + if (this.isDid(cachedDid) && (await this.ucanVerifiesAgainstOwner(token, contractAddress, cachedDid))) { + return cachedDid; } - - const result = await ucans.verify(token, { - audience: this.serverDid, - requiredCapabilities: [ - { - capability: { - with: { scheme: "storage", hierPart: contractAddress.toLowerCase() }, - can: { namespace: "collaboration", segments: ["CREATE"] }, - }, - rootIssuer: ownerDid, - }, - ], - }); - - if (result.ok) { - return ownerDid; - } - return null; + // The cached DID may predate a workspace rotation (member removal + // re-registers the collab DID) — one rate-limited fresh chain read, + // then a single re-verify. + const memoKey = `recheck-${contractAddress}-${collaboratorAddress}`; + if (this.ownerDidRecheckMemo.get(memoKey)) return null; + this.ownerDidRecheckMemo.set(memoKey, true); + const freshDid = await refreshOwnerDid(contractAddress, collaboratorAddress); + if (!this.isDid(freshDid) || freshDid === cachedDid) return null; + return (await this.ucanVerifiesAgainstOwner(token, contractAddress, freshDid)) ? freshDid : null; } catch (error) { console.error("UCAN verification error:", error); return null; } } + private isDid(value: unknown): value is string { + // Reject anything that isn't a DID string before it reaches ucans.verify + // (which throws a TypeError on a malformed rootIssuer instead of failing + // the verification) — an unregistered or mis-decoded collaborator must + // 401 cleanly. + return typeof value === "string" && value.startsWith("did:"); + } + + private async ucanVerifiesAgainstOwner(token: string, contractAddress: Hex, rootIssuer: string): Promise { + const result = await ucans.verify(token, { + audience: this.serverDid, + requiredCapabilities: [ + { + capability: { + with: { scheme: "storage", hierPart: contractAddress.toLowerCase() }, + can: { namespace: "collaboration", segments: ["CREATE"] }, + }, + rootIssuer, + }, + ], + }); + return result.ok; + } + async verifyCollaborationToken(token: string, sessionDid: string, documentId: string) { const cacheKey = `${token}:${sessionDid}:${documentId}`; const cachedResult = this.collaborationTokenCache.get(cacheKey); diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index 8a64fa3..c80b547 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -256,6 +256,14 @@ export class MongoDBStore { ); } + async getDocumentMeta( + documentId: string + ): Promise<{ editLock: string | null; title: string | null } | null> { + const meta: any = await DocumentMetaModel.findById(documentId).select("editLock title").lean(); + if (!meta) return null; + return { editLock: meta.editLock ?? null, title: meta.title ?? null }; + } + // 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 } diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index bff83d4..f8acfd9 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -5,7 +5,16 @@ import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; import type { GateEpochCache, EditBoundCache } from "./gate-epoch"; import { getRoomName } from "./socket-handlers"; -import { Hex } from "viem"; +import { Hex, isAddress } from "viem"; +import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../utils/contract"; + +// Duplicated from socket-handlers.ts (not exported there); keep in sync if that changes. +function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { + if (!address || !isAddress(address)) { + return false; + } + return true; +} export interface OwnerOpDeps { authService: Pick; @@ -276,3 +285,68 @@ export function createMirrorReadHandler(deps: { mongodbStore: Pick; + sessionManager: Pick; + }, + io: AppServer +) { + return async (req: Request, res: Response): Promise => { + const portalAddress = req.params.portalAddress as Hex; + const { memberIdentityDid, ownerToken, ownerAddress } = req.body || {}; + + if ( + !validateHexAddress(portalAddress, "portalAddress") || + !validateHexAddress(ownerAddress, "ownerAddress") + ) { + res.status(400).json({ error: "Invalid portal or owner address" }); + return; + } + if (typeof memberIdentityDid !== "string" || !memberIdentityDid.startsWith("did:")) { + res.status(400).json({ error: "memberIdentityDid must be a DID string" }); + return; + } + if (typeof ownerToken !== "string" || !ownerToken) { + res.status(400).json({ error: "ownerToken is required" }); + return; + } + + // Owner-only: the presented address must BE the portal creator + // (Portal.owner() = the workspace Admin Sub-Agent). collaboratorKeys(MSA) + // is the shared member DID every member can sign for — it lives at a + // different address and never passes this gate. + const portalOwner = await getPortalOwnerAddress(portalAddress); + if (!portalOwner || portalOwner.toLowerCase() !== String(ownerAddress).toLowerCase()) { + res.status(403).json({ error: "Not the portal owner" }); + return; + } + const ownerDid = await deps.authService.verifyOwnerToken( + ownerToken, + portalAddress, + ownerAddress as Hex + ); + if (!ownerDid) { + res.status(403).json({ error: "Owner token verification failed" }); + return; + } + + bustOwnerDidCacheForPortal(portalAddress); + + const sessions = await deps.sessionManager.getNonTerminatedSessionsForPortal(portalAddress); + let dropped = 0; + for (const s of sessions) { + const roomName = getRoomName(s.documentId, s.sessionDid); + const sockets = await io.in(roomName).fetchSockets(); + for (const sock of sockets) { + if (sock.data.actorIdentityDid === memberIdentityDid) { + sock.disconnect(true); + dropped++; + } + } + } + + res.status(200).json({ ok: true, sessions: sessions.length, dropped }); + }; +} diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 9674c12..260f059 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -107,6 +107,23 @@ export class SessionManager { } } + // Rotation heal: the chain re-registered the portal collab DID (member + // removal); adopt it so compares against the stored value keep working. + async updateSessionOwnerDid( + documentId: string, + sessionDid: string, + ownerDid: string + ): Promise { + try { + await SessionModel.updateOne({ documentId, sessionDid }, { $set: { ownerDid } }); + const sessionKey = this.getSessionKey(documentId, sessionDid); + const mem = this.inMemorySessions.get(sessionKey); + if (mem) mem.ownerDid = ownerDid; + } catch (error) { + console.error("Error updating session ownerDid:", error); + } + } + async getSession(documentId: string, sessionDid: string): Promise { // Check in-memory first for active sessions const sessionKey = this.getSessionKey(documentId, sessionDid); @@ -269,10 +286,17 @@ export class SessionManager { async getOtherNonTerminatedSessions( documentId: string, ownerDid: string, + portalAddress: string | null, excludeSessionDid?: string ): Promise> { try { - const query: Record = { documentId, ownerDid, state: { $ne: "terminated" } }; + const or: Record[] = [{ ownerDid }]; + if (portalAddress && /^0x[0-9a-fA-F]{40}$/.test(portalAddress)) { + // Post-rotation sessions store the old ownerDid — the portal match is + // what lets a new-DID owner sweep them. + or.push({ portalAddress: { $regex: `^${portalAddress}$`, $options: "i" } }); + } + const query: Record = { documentId, state: { $ne: "terminated" }, $or: or }; if (excludeSessionDid) { query.sessionDid = { $ne: excludeSessionDid }; } @@ -306,6 +330,28 @@ export class SessionManager { } } + async getNonTerminatedSessionsForPortal( + portalAddress: string + ): Promise> { + if (!/^0x[0-9a-fA-F]{40}$/.test(portalAddress)) return []; + try { + const sessions = await SessionModel.find( + { + portalAddress: { $regex: `^${portalAddress}$`, $options: "i" }, + state: { $ne: "terminated" }, + }, + { documentId: 1, sessionDid: 1 } + ).lean(); + return sessions.map((s: any) => ({ + documentId: s.documentId, + sessionDid: s.sessionDid, + })); + } catch (error) { + console.error("Error getting non-terminated sessions for portal:", error); + return []; + } + } + async getActiveSessionsCount(): Promise { const inMemoryCount = this.inMemorySessions.size; if (inMemoryCount > 0) { diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index d003847..c2d22e1 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -158,6 +158,7 @@ export async function handleAuth( let railKind: "gp-actor" | "gp-legacy" | "workspace" | "public" | undefined = undefined; let admittedEditGrantEpoch: number | undefined = undefined; let actorHandle: string | undefined = undefined; + let provenIdentityDid: string | null = null; // 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 @@ -232,12 +233,14 @@ export async function handleAuth( }); } boundOwnerIdentityDid = provenSigningDid; + provenIdentityDid = provenSigningDid; } // Terminate other sessions with socket notification const otherSessions = await sessionManager.getOtherNonTerminatedSessions( documentId, ownerDid, + args.contractAddress ?? null, sessionDid ); for (const oldSession of otherSessions) { @@ -334,6 +337,29 @@ export async function handleAuth( ); } + // Rotation heal: the chain is the root of trust for the portal collab + // DID; the stored session value is a cache of it. A verified ownerToken + // for the session's OWN portal that resolves to a different DID means + // the workspace rotated (member removal) — adopt it, or the whole team + // stays locked out of the established session. A foreign portal's owner + // fails the address guard; a removed member's stale token never + // verifies against the rotated chain slot. + if ( + ownerDid && + existingSession.portalAddress && + args.contractAddress && + existingSession.portalAddress.toLowerCase() === + args.contractAddress.toLowerCase() && + ownerDid !== existingSession.ownerDid + ) { + await sessionManager.updateSessionOwnerDid( + documentId, + existingSession.sessionDid, + ownerDid + ); + existingSession.ownerDid = ownerDid; + } + // Identity-based role (ddoc-only): 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 @@ -361,6 +387,7 @@ export async function handleAuth( provenDid === boundIdentityDid && ownerDid === existingSession.ownerDid ? "owner" : "editor"; + provenIdentityDid = provenDid; } else if ( storedAppType === "ddoc" && boundIdentityDid && @@ -393,6 +420,7 @@ export async function handleAuth( documentId ); if (provenSigningDid) { + provenIdentityDid = provenSigningDid; await sessionManager.fillOwnerIdentityDidIfAbsent( documentId, existingSession.sessionDid, @@ -510,6 +538,7 @@ export async function handleAuth( socket.data.railKind = railKind; socket.data.admittedEditGrantEpoch = admittedEditGrantEpoch; socket.data.actorHandle = actorHandle; + socket.data.actorIdentityDid = provenIdentityDid ?? undefined; // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); @@ -528,6 +557,12 @@ export async function handleAuth( }; socket.to(roomName).emit("/room/membership_change", membershipPayload); + // Latest stored title beats the session-frozen roomInfo blob for joiners + // arriving after a mid-session rename. + const documentMeta = await deps.mongodbStore + .getDocumentMeta(documentId) + .catch(() => null); + callback({ status: true, statusCode: 200, @@ -536,6 +571,7 @@ export async function handleAuth( role, sessionType, roomInfo, + title: documentMeta?.title ?? null, }, }); } catch (error) { @@ -1084,6 +1120,12 @@ export async function handleSetDocumentMeta( title: args.title, }); + const roomName = getRoomName(documentId, socket.data.sessionDid); + socket.to(roomName).emit("/document/meta_update", { + roomId: documentId, + title: args.title, + }); + callback({ status: true, statusCode: 200, @@ -1208,6 +1250,17 @@ export async function handleTerminateSession( ownerAddress ); + if ( + ownerDid && + session.portalAddress && + contractAddress && + session.portalAddress.toLowerCase() === String(contractAddress).toLowerCase() && + ownerDid !== session.ownerDid + ) { + await sessionManager.updateSessionOwnerDid(documentId, session.sessionDid, ownerDid); + session.ownerDid = ownerDid; + } + if (ownerDid !== session.ownerDid) { return callback({ status: false, diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 7283fdf..6733567 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as ucans from "@ucans/ucans"; import { AuthService } from "../../services/auth"; -import { getIdentitySigningDid, getOwnerDid } from "../../utils/contract"; +import { getIdentitySigningDid, getOwnerDid, refreshOwnerDid } from "../../utils/contract"; vi.mock("@ucans/ucans", () => ({ verify: vi.fn(), validate: vi.fn() })); vi.mock("../../utils/contract", () => ({ getIdentitySigningDid: vi.fn(), getOwnerDid: vi.fn(), + refreshOwnerDid: vi.fn(), })); describe("verifyCollaborationToken cache (R4a)", () => { @@ -67,6 +68,52 @@ describe("verifyIdentityToken", () => { }); }); +describe("verifyOwnerToken rotation re-read", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-reads the chain once when the cached DID fails verification and accepts the fresh DID", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:old"); + (refreshOwnerDid as any).mockResolvedValue("did:key:new"); + (ucans.verify as any).mockImplementation((_token: string, opts: any) => + Promise.resolve({ ok: opts.requiredCapabilities[0].rootIssuer === "did:key:new" }) + ); + const auth = new AuthService("did:server"); + + const did = await auth.verifyOwnerToken("tok", "0xContract" as any, "0xCollab" as any); + + expect(did).toBe("did:key:new"); + expect(refreshOwnerDid).toHaveBeenCalledTimes(1); + expect(refreshOwnerDid).toHaveBeenCalledWith("0xContract", "0xCollab"); + }); + + it("rate-limits the re-read per (contract, address)", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:old"); + (refreshOwnerDid as any).mockResolvedValue("did:key:still-wrong"); + (ucans.verify as any).mockResolvedValue({ ok: false }); + const auth = new AuthService("did:server"); + + const first = await auth.verifyOwnerToken("badtok", "0xContract" as any, "0xCollab" as any); + const second = await auth.verifyOwnerToken("badtok", "0xContract" as any, "0xCollab" as any); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(refreshOwnerDid).toHaveBeenCalledTimes(1); + }); + + it("returns the cached DID on the happy path without any re-read", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:owner"); + (ucans.verify as any).mockResolvedValue({ ok: true }); + const auth = new AuthService("did:server"); + + const did = await auth.verifyOwnerToken("tok", "0xContract" as any, "0xCollab" as any); + + expect(did).toBe("did:key:owner"); + expect(refreshOwnerDid).not.toHaveBeenCalled(); + }); +}); + describe("verifyOwnerOp (OR of two bound proofs)", () => { let auth: AuthService; beforeEach(() => { diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 06b82af..be29b7d 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createWorkspaceEditTierHandler } from "../../services/owner-op-routes"; -import { createRefreshEditGrantHandler, createMirrorReadHandler } from "../../services/owner-op-routes"; +import { createRefreshEditGrantHandler, createMirrorReadHandler, createEvictWorkspaceMemberHandler } from "../../services/owner-op-routes"; import { getRoomName } from "../../services/socket-handlers"; +import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../../utils/contract"; + +vi.mock("../../utils/contract", () => ({ + getPortalOwnerAddress: vi.fn(), + bustOwnerDidCacheForPortal: vi.fn(), +})); function res() { const r: any = {}; @@ -377,3 +383,138 @@ describe("GET /documents/:id/mirror", () => { expect(r.status).toHaveBeenCalledWith(404); }); }); + +describe("createEvictWorkspaceMemberHandler", () => { + const portalAddress = "0x0000000000000000000000000000000000000002"; + const ownerAddress = "0x0000000000000000000000000000000000000001"; // Portal.owner() (ASA) + const msaAddress = "0x0000000000000000000000000000000000000003"; // shared member DID address — must NOT pass + let deps: any, io: any; + + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyOwnerToken: vi.fn() }, + sessionManager: { getNonTerminatedSessionsForPortal: vi.fn().mockResolvedValue([]) }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + (getPortalOwnerAddress as any).mockResolvedValue(ownerAddress); + }); + + it("drops only sockets whose actorIdentityDid matches, across all portal sessions", async () => { + deps.authService.verifyOwnerToken.mockResolvedValue("did:key:owner"); + deps.sessionManager.getNonTerminatedSessionsForPortal.mockResolvedValue([ + { documentId: "doc1", sessionDid: "sess1" }, + { documentId: "doc2", sessionDid: "sess2" }, + ]); + const matchSock: any = { data: { actorIdentityDid: "did:key:member" }, disconnect: vi.fn() }; + const otherMemberSock: any = { data: { actorIdentityDid: "did:key:other" }, disconnect: vi.fn() }; + const undefinedDidSock: any = { data: {}, disconnect: vi.fn() }; + const otherRoomSock: any = { data: { actorIdentityDid: "did:key:other2" }, disconnect: vi.fn() }; + const byRoom: Record = { + [getRoomName("doc1", "sess1")]: [matchSock, otherMemberSock, undefinedDidSock], + [getRoomName("doc2", "sess2")]: [otherRoomSock], + }; + io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress }, + } as any, + r + ); + + expect(matchSock.disconnect).toHaveBeenCalledWith(true); + expect(otherMemberSock.disconnect).not.toHaveBeenCalled(); + expect(undefinedDidSock.disconnect).not.toHaveBeenCalled(); + expect(otherRoomSock.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, sessions: 2, dropped: 1 }); + expect(bustOwnerDidCacheForPortal).toHaveBeenCalledWith(portalAddress); + }); + + it("403s when ownerAddress is not Portal.owner() (e.g. the MSA)", async () => { + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress: msaAddress }, + } as any, + r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(r.json).toHaveBeenCalledWith({ error: "Not the portal owner" }); + expect(deps.authService.verifyOwnerToken).not.toHaveBeenCalled(); + expect(deps.sessionManager.getNonTerminatedSessionsForPortal).not.toHaveBeenCalled(); + }); + + it("403s when the owner token does not verify", async () => { + deps.authService.verifyOwnerToken.mockResolvedValue(null); + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "bad-tok", ownerAddress }, + } as any, + r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(r.json).toHaveBeenCalledWith({ error: "Owner token verification failed" }); + expect(deps.sessionManager.getNonTerminatedSessionsForPortal).not.toHaveBeenCalled(); + expect(bustOwnerDidCacheForPortal).not.toHaveBeenCalled(); + }); + + it("400s on malformed memberIdentityDid or addresses", async () => { + const r1 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "not-a-did", ownerToken: "tok", ownerAddress }, + } as any, + r1 + ); + expect(r1.status).toHaveBeenCalledWith(400); + expect(r1.json).toHaveBeenCalledWith({ error: "memberIdentityDid must be a DID string" }); + + const r2 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress: "not-an-address" }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress }, + } as any, + r2 + ); + expect(r2.status).toHaveBeenCalledWith(400); + expect(r2.json).toHaveBeenCalledWith({ error: "Invalid portal or owner address" }); + + const r3 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress: "not-an-address" }, + } as any, + r3 + ); + expect(r3.status).toHaveBeenCalledWith(400); + expect(r3.json).toHaveBeenCalledWith({ error: "Invalid portal or owner address" }); + + const r4 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerAddress }, + } as any, + r4 + ); + expect(r4.status).toHaveBeenCalledWith(400); + expect(r4.json).toHaveBeenCalledWith({ error: "ownerToken is required" }); + + // None of the malformed-input cases should have reached the chain read. + expect(getPortalOwnerAddress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index 332b041..5770ad6 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -8,6 +8,7 @@ vi.mock("../../database/models", () => ({ findOneAndUpdate: vi.fn(), find: vi.fn(), countDocuments: vi.fn(), + updateOne: vi.fn(), }, DocumentUpdateModel: { deleteMany: vi.fn(), @@ -200,13 +201,14 @@ describe("SessionManager", () => { const sessions = await sessionManager.getOtherNonTerminatedSessions( "doc-1", "owner-did", + null, "session-1" ); expect(SessionModel.find).toHaveBeenCalledWith({ documentId: "doc-1", - ownerDid: "owner-did", state: { $ne: "terminated" }, + $or: [{ ownerDid: "owner-did" }], sessionDid: { $ne: "session-1" }, }); expect(sessions).toEqual([ @@ -384,4 +386,95 @@ describe("SessionManager", () => { await freshManager.destroy(); }); + + // updateSessionOwnerDid + + describe("updateSessionOwnerDid", () => { + it("persists the new ownerDid and mirrors it in memory", async () => { + const freshManager = new SessionManager(); + await freshManager.createSession({ + documentId: "doc1", + sessionDid: "sess1", + ownerDid: "did:key:old", + portalAddress: "0x" + "a".repeat(40), + collabJoinEnabled: false, + } as any); + vi.clearAllMocks(); + + await freshManager.updateSessionOwnerDid("doc1", "sess1", "did:key:new"); + + expect(SessionModel.updateOne).toHaveBeenCalledWith( + { documentId: "doc1", sessionDid: "sess1" }, + { $set: { ownerDid: "did:key:new" } } + ); + const session = await freshManager.getSession("doc1", "sess1"); + expect(session?.ownerDid).toBe("did:key:new"); + + await freshManager.destroy(); + }); + }); + + // getNonTerminatedSessionsForPortal + + describe("getNonTerminatedSessionsForPortal", () => { + it("matches portalAddress case-insensitively and excludes terminated", async () => { + const portal = "0x" + "A".repeat(40); + vi.mocked(SessionModel.find).mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ documentId: "doc-1", sessionDid: "sess-1" }]), + } as any); + + const sessions = await sessionManager.getNonTerminatedSessionsForPortal(portal); + + expect(SessionModel.find).toHaveBeenCalledWith( + { + portalAddress: { $regex: `^${portal}$`, $options: "i" }, + state: { $ne: "terminated" }, + }, + { documentId: 1, sessionDid: 1 } + ); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "sess-1" }]); + }); + + it("returns [] for a non-hex portalAddress without querying", async () => { + const out = await sessionManager.getNonTerminatedSessionsForPortal("not-an-address');--"); + + expect(out).toEqual([]); + expect(SessionModel.find).not.toHaveBeenCalled(); + }); + }); + + // getOtherNonTerminatedSessions ($or sweep) + + describe("getOtherNonTerminatedSessions ($or sweep)", () => { + it("matches by portalAddress even when ownerDid differs (post-rotation orphan)", async () => { + const portal = "0x" + "a".repeat(40); + vi.mocked(SessionModel.find).mockResolvedValue([ + { documentId: "doc-1", sessionDid: "orphan-session", ownerDid: "did:key:old", appType: "ddoc" }, + ] as any); + + const sessions = await sessionManager.getOtherNonTerminatedSessions("doc-1", "did:key:new", portal); + + expect(SessionModel.find).toHaveBeenCalledWith({ + documentId: "doc-1", + state: { $ne: "terminated" }, + $or: [{ ownerDid: "did:key:new" }, { portalAddress: { $regex: `^${portal}$`, $options: "i" } }], + }); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "orphan-session", appType: "ddoc" }]); + }); + + it("still matches legacy null-portal sessions by ownerDid", async () => { + vi.mocked(SessionModel.find).mockResolvedValue([ + { documentId: "doc-1", sessionDid: "legacy-session", ownerDid: "did:key:x", appType: "ddoc" }, + ] as any); + + const sessions = await sessionManager.getOtherNonTerminatedSessions("doc-1", "did:key:x", null); + + expect(SessionModel.find).toHaveBeenCalledWith({ + documentId: "doc-1", + state: { $ne: "terminated" }, + $or: [{ ownerDid: "did:key:x" }], + }); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "legacy-session", appType: "ddoc" }]); + }); + }); }); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index 41a2110..8b667ef 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -61,8 +61,9 @@ describe("handleAuth", () => { getCollabJoinEnabled: vi.fn(), getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), + updateSessionOwnerDid: vi.fn(), }; - const fakeMongoDBStore = {} as any; + const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; const fakeGateEpochCache = { getEditGrantEpoch: vi.fn(), refreshEditGrantEpoch: vi.fn(), @@ -178,6 +179,7 @@ describe("handleAuth", () => { expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", + fakeArgs.contractAddress, fakeArgs.sessionDid, ); expect(fakeSessionManager.createSession).toHaveBeenCalledWith({ @@ -220,10 +222,76 @@ describe("handleAuth", () => { role: "owner", sessionType: "new", roomInfo: fakeArgs.roomInfo, + title: null, }, }); }); + it("returns the latest stored DocumentMeta title in the ack", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + 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"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeMongoDBStore.getDocumentMeta.mockResolvedValueOnce({ editLock: null, title: "enc-title" }); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeMongoDBStore.getDocumentMeta).toHaveBeenCalledWith("doc-1"); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + data: expect.objectContaining({ title: "enc-title" }), + }) + ); + }); + + it("returns a null ack title when the DocumentMeta read fails", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + 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"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeMongoDBStore.getDocumentMeta.mockRejectedValueOnce(new Error("mongo down")); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + data: expect.objectContaining({ title: null }), + }) + ); + }); + it("creates a new owner session and terminates other active sessions when they exist", async () => { const fakeRoomBroadcastOperator = { emit: vi.fn() }; const fetchSockets = vi.fn(); @@ -283,6 +351,7 @@ describe("handleAuth", () => { expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", + fakeArgs.contractAddress, fakeArgs.sessionDid, ); @@ -360,6 +429,7 @@ describe("handleAuth", () => { role: "owner", sessionType: "new", roomInfo: fakeArgs.roomInfo, + title: null, }, }); }); @@ -454,6 +524,7 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -698,6 +769,7 @@ describe("handleAuth", () => { role: "owner", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -797,6 +869,7 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -899,6 +972,7 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -1267,6 +1341,141 @@ describe("handleAuth", () => { expect(fakeSocket.data.rail).toBe("workspace"); expect(fakeSocket.data.railKind).toBe("workspace"); }); + + describe("rotation heal", () => { + const rotatedSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:old", + ownerIdentityDid: "did:key:creator", + portalAddress: "0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa", + appType: "ddoc", + }; + const rotatedJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + // Same portal as rotatedSession.portalAddress, different case. + contractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + joinOnly: true, + }; + + it("adopts a rotated ownerDid for the session's own portal and admits the workspace member", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...rotatedJoinArgs }, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + "doc-1", + "session-1", + "did:key:new" + ); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(fakeSocket.data.rail).toBe("workspace"); + }); + + it("does NOT heal when the presented contractAddress differs from session.portalAddress", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + ...rotatedJoinArgs, + contractAddress: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + callback + ); + + expect(fakeSessionManager.updateSessionOwnerDid).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("does NOT heal when portalAddress is null (legacy session)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession, portalAddress: null }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...rotatedJoinArgs }, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).not.toHaveBeenCalled(); + }); + + it("restores owner role for the identity-bound creator after rotation", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + ...rotatedJoinArgs, + joinOnly: false, + identityToken: "identity-token", + identityContractAddress: "0x3333333333333333333333333333333333333333", + }, + callback + ); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + "doc-1", + "session-1", + "did:key:new" + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + ); + }); + }); + + describe("actorIdentityDid stamp", () => { + it("stamps the proven identity DID on the socket for a member join", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(fakeSocket.data.actorIdentityDid).toBe("did:key:member"); + }); + + it("leaves actorIdentityDid undefined when no identityToken is presented", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, tokenless as any, callback); + + expect(fakeSocket.data.actorIdentityDid).toBeUndefined(); + }); + }); }); describe("handleAuth — edit-claim admission (existing session, non-owner)", () => { @@ -1288,7 +1497,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), }; - const fakeMongoDBStore = {} as any; + const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; const fakeGateEpochCache = { getEditGrantEpoch: vi.fn(), refreshEditGrantEpoch: vi.fn(), diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index 3ab32f0..0f83c76 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -4,8 +4,16 @@ import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; import type { AppSocket } from "../../types"; import { ErrorCode } from "../../types"; -function fakeSocket(role: "owner" | "editor"): AppSocket { - return { id: "s1", data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" } } as unknown as AppSocket; +function fakeSocket( + role: "owner" | "editor", + broadcastOperator?: { emit: ReturnType } +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + return { + id: "s1", + data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" }, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; } describe("handleSetDocumentMeta", () => { @@ -25,8 +33,11 @@ describe("handleSetDocumentMeta", () => { it("rejects a non-owner", async () => { const cb = vi.fn(); - await handleSetDocumentMeta(deps, fakeSocket("editor"), { editLock: "el", title: "t" }, cb); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("editor", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "t" }, cb); expect(upsertDocumentMeta).not.toHaveBeenCalled(); + expect(broadcast.emit).not.toHaveBeenCalled(); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.COMMIT_UNAUTHORIZED })); }); @@ -38,4 +49,37 @@ describe("handleSetDocumentMeta", () => { })); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); }); + + it("broadcasts the meta update to room peers, not back to the sender", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "enc-title" }, cb); + expect(socket.to).toHaveBeenCalledWith("session::doc-1__room-did"); + expect(broadcast.emit).toHaveBeenCalledWith("/document/meta_update", { + roomId: "doc-1", + title: "enc-title", + }); + }); + + it("broadcasts a null title", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: null }, cb); + expect(broadcast.emit).toHaveBeenCalledWith("/document/meta_update", { + roomId: "doc-1", + title: null, + }); + }); + + it("does not broadcast when the session lookup fails", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + getRuntimeSession.mockResolvedValueOnce(null); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "t" }, cb); + expect(broadcast.emit).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 })); + }); }); diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index 96539ae..87e9661 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -43,6 +43,7 @@ describe("handleTerminateSession", () => { getSession: vi.fn(), deactivateSession: vi.fn(), terminateSession: vi.fn(), + updateSessionOwnerDid: vi.fn(), }; const fakeMongoDBStore = {} as any; const deps: SocketHandlerDeps = { @@ -310,6 +311,50 @@ describe("handleTerminateSession", () => { ); }); + it("lets the owner terminate after a rotation (heal instead of 401)", async () => { + const fetchSocketsResponse: any[] = []; + const fakeIO = createFakeIO(fetchSocketsResponse); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const fakeSessionResponse = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "did:key:old", + portalAddress: "0x0000000000000000000000000000000000000002", + appType: "ddoc" as const, + }; + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid, + "did:key:new" + ); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid, + "ddoc" + ); + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { message: "Session terminated" }, + }); + }); + it("returns 500 when an unexpected error occurs in terminate session handler", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); diff --git a/src/tests/utils/contract.test.ts b/src/tests/utils/contract.test.ts index 1d86421..362522b 100644 --- a/src/tests/utils/contract.test.ts +++ b/src/tests/utils/contract.test.ts @@ -31,3 +31,57 @@ describe("getIdentitySigningDid", () => { expect(did).toBeNull(); }); }); + +describe("bustOwnerDidCacheForPortal", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("drops all collaborator entries for the portal regardless of casing, leaves other portals", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockResolvedValue(["0xAgent", "did:key:zCollab"] as any); + + const portalMixedCase = "0xAaAa000000000000000000000000000000aAaA"; + const portalOppositeCase = "0xaAAA000000000000000000000000000000AaAa"; + const otherPortal = "0xBbBb000000000000000000000000000000bBbB"; + const collaboratorA = "0x1111000000000000000000000000000000cccc"; + const collaboratorB = "0x2222000000000000000000000000000000dddd"; + + await contract.getOwnerDid(portalMixedCase as any, collaboratorA as any); + await contract.getOwnerDid(otherPortal as any, collaboratorB as any); + expect(spy).toHaveBeenCalledTimes(2); + + contract.bustOwnerDidCacheForPortal(portalOppositeCase); + + // Busted portal: cache entry gone, so a fresh chain read happens. + await contract.getOwnerDid(portalMixedCase as any, collaboratorA as any); + expect(spy).toHaveBeenCalledTimes(3); + + // Other portal: cache entry untouched, no additional chain read. + await contract.getOwnerDid(otherPortal as any, collaboratorB as any); + expect(spy).toHaveBeenCalledTimes(3); + }); +}); + +describe("getPortalOwnerAddress", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reads owner() once and caches it", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockResolvedValue("0xOwnerAddress0000000000000000000000000000"); + + const first = await contract.getPortalOwnerAddress("0xPortalOwnerAddr000000000000000000000000" as any); + const second = await contract.getPortalOwnerAddress("0xPortalOwnerAddr000000000000000000000000" as any); + + expect(first).toBe("0xOwnerAddress0000000000000000000000000000"); + expect(second).toBe("0xOwnerAddress0000000000000000000000000000"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "owner" }) + ); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 552f685..7c598d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -127,6 +127,9 @@ export interface AuthResponseData { role: "owner" | "editor"; sessionType: "new" | "existing"; roomInfo?: string; + /** Latest stored roomKey-encrypted title (DocumentMeta) — fresher than the + * session-frozen roomInfo blob after a mid-session rename. */ + title?: string | null; } export interface DocumentUpdateArgs { @@ -264,6 +267,12 @@ export interface MembershipChangePayload { roomId: string; } +export interface MetaUpdatePayload { + roomId: string; + /** roomKey-encrypted; the server never sees the plaintext title. */ + title: string | null; +} + export interface SessionTerminatedPayload { roomId: string; } @@ -302,6 +311,7 @@ export interface ServerToClientEvents { "/server/error": (data: ServerErrorPayload) => void; "/document/content_update": (data: ContentUpdatePayload) => void; "/document/awareness_update": (data: AwarenessUpdatePayload) => void; + "/document/meta_update": (data: MetaUpdatePayload) => void; "/room/membership_change": (data: MembershipChangePayload) => void; "/session/terminated": (data: SessionTerminatedPayload) => void; } @@ -320,6 +330,7 @@ export interface SocketData { railKind?: "gp-actor" | "gp-legacy" | "workspace" | "public"; admittedEditGrantEpoch?: number; actorHandle?: string; + actorIdentityDid?: string; } // *************************************** diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 531f1c9ea3fc9137ef60a675b9a28be4f59048cb..afa2fac8fd019114abd84cb251ed441c2d117d25 100644 GIT binary patch delta 660 zcmah{%}N|W5Wa}Uah5U3`8$GxS}orF<_EQASc`D+DyAc_tIVJE{m8a z2!TH4B?5U4!B@!}SUsH;l1t{)Q(fO*Re#(0KKOZ;u2>vdbdA4TKK4J>4xWp;!BS$I z)!}r4v%K<$>G&4FB3)VyQpqa0sgh)S8$KEmq>P4(OGnTOEm6YASfEBZm>5fwa=Z{nq9+;Xk?CjP2A7Dx5ow-JPBphb>O>tHw&`l#Ku!}Lyg41rW zCI7fA&zTzdQzOW@@;5`ti7dHKikfUa>M|h0!-g1;`!v~j7Nc|l|77r=*Eao=-kYts zgTjRH#eW=o(fhCt5Ec?$McWe)!kqB>7e%jPD|u^ delta 12 TcmbPW*JrZfEYIfif{wfZBen$l From 32136e42a858645b882e815e1caffb664a0e984f Mon Sep 17 00:00:00 2001 From: Nadeem Date: Fri, 17 Jul 2026 18:40:56 +0530 Subject: [PATCH 06/11] refactor(collab): replace editGrantEpoch rail with deterministic per-actor revocation Remove the legacy gp editGrantEpoch admission rail (GateEpochCache and the /refresh-edit-grant owner op) and enforce edit revocation deterministically: - evict-edit-actors hard-kicks matching gp-actor sockets across all of a document's session rooms (was cache-bust only) - the write and mirror chokepoints disconnect the socket on a failed admission re-check instead of only returning 403 - a throttled awareness re-check drops revoked-but-idle actors Only the targeted actor is affected: every disconnect traces to an exact owner-supplied editHandle or that socket's own failed re-check, and a gate error never kicks (stale-bound grace). --- src/index.ts | 15 +- src/services/auth.ts | 14 +- src/services/gate-epoch.ts | 61 +----- src/services/owner-op-routes.ts | 60 ++---- src/services/socket-handlers.deps.ts | 3 +- src/services/socket-handlers.ts | 60 +++--- src/tests/services/auth.test.ts | 11 +- src/tests/services/gate-epoch.test.ts | 111 ----------- src/tests/services/owner-op-routes.test.ts | 179 ++++++++---------- .../socket-handlers.handleAuth.test.ts | 48 +---- .../socket-handlers.handleAwareness.test.ts | 60 +++++- ...ocket-handlers.handleCommitHistory.test.ts | 1 - ...ocket-handlers.handleDisconnecting.test.ts | 1 - ...cket-handlers.handleDocumentCommit.test.ts | 1 - ...cket-handlers.handleDocumentUpdate.test.ts | 38 ++-- ...cket-handlers.handleMirrorSnapshot.test.ts | 36 +++- ...ket-handlers.handleSetDocumentMeta.test.ts | 1 - .../socket-handlers.handleSnapshot.test.ts | 1 - ...ocket-handlers.handleUpdateHistory.test.ts | 1 - .../socket-handlers.terminateSession.test.ts | 1 - src/types/index.ts | 4 +- 21 files changed, 247 insertions(+), 460 deletions(-) delete mode 100644 src/tests/services/gate-epoch.test.ts diff --git a/src/index.ts b/src/index.ts index 86ce139..19c1011 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,14 +16,13 @@ import { databaseService } from "./database"; import { createCollabJoinEnabledHandler, createWorkspaceEditTierHandler, - createRefreshEditGrantHandler, createEvictEditActorsHandler, createEvictWorkspaceMemberHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createMirrorReadHandler, } from "./services/owner-op-routes"; -import { gateEpochCache, editBoundCache } from "./services/gate-epoch"; +import { editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; @@ -105,13 +104,9 @@ class CollaborationServer { "/documents/:documentId/workspace-edit-tier", createWorkspaceEditTierHandler({ authService, sessionManager }, this.io) ); - this.app.post( - "/documents/:documentId/refresh-edit-grant", - createRefreshEditGrantHandler({ authService, sessionManager, gateEpochCache }, this.io) - ); this.app.post( "/documents/:documentId/evict-edit-actors", - createEvictEditActorsHandler({ authService, sessionManager, editBoundCache }) + createEvictEditActorsHandler({ authService, sessionManager, editBoundCache }, this.io) ); this.app.post( "/workspaces/:portalAddress/evict-member", @@ -155,10 +150,10 @@ class CollaborationServer { "[startup] GATE_DID is not set — GP (private/group) live editing is DISABLED; only owner/workspace/public rails admit writes." ); } else if (!config.gate.url) { - // GATE_DID without GATE_URL: verifyEditUcan admits GP joins but the epoch cache can - // never reach the gate, so every revocation (demote/revoke) is a permanent silent no-op. + // GATE_DID without GATE_URL: verifyEditUcan admits GP joins but the edit-bound cache + // can never reach the gate, so every revocation (demote/revoke) is a permanent silent no-op. throw new Error( - "[startup] GATE_DID is set but GATE_URL is not — GP editGrantEpoch revocation would never take effect. Set GATE_URL to the gate origin, or unset GATE_DID to disable GP editing." + "[startup] GATE_DID is set but GATE_URL is not — GP edit revocation would never take effect. Set GATE_URL to the gate origin, or unset GATE_DID to disable GP editing." ); } diff --git a/src/services/auth.ts b/src/services/auth.ts index bcd66d7..c355b69 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -100,17 +100,12 @@ export class AuthService { /** * Verify a gate-minted edit-admission UCAN, rooted at the pinned gate DID. Returns the - * signed facts — actor UCANs carry `editHandle`, legacy UCANs carry `editGrantEpoch`+`nullifier` — - * or null. These come from the token, never a client arg. + * signed `editHandle` fact, or null. It comes from the token, never a client arg. */ async verifyEditUcan( token: string, documentId: string - ): Promise< - | { kind: "legacy"; editGrantEpoch: number; nullifier: string } - | { kind: "actor"; editHandle: string } - | null - > { + ): Promise<{ kind: "actor"; editHandle: string } | null> { if (!this.gateDid) return null; // GP editing disabled until GATE_DID is pinned try { const result = await ucans.verify(token, { @@ -130,15 +125,10 @@ export class AuthService { const parsed = await ucans.validate(token); const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { docId?: string; - editGrantEpoch?: number; - nullifier?: string; editHandle?: string; }; if (fact.docId !== documentId) return null; if (typeof fact.editHandle === "string") return { kind: "actor", editHandle: fact.editHandle }; - if (typeof fact.editGrantEpoch === "number" && typeof fact.nullifier === "string") { - return { kind: "legacy", editGrantEpoch: fact.editGrantEpoch, nullifier: fact.nullifier }; - } return null; } catch (error) { console.error("Edit UCAN verification error:", error); diff --git a/src/services/gate-epoch.ts b/src/services/gate-epoch.ts index 2c6189b..00b74d7 100644 --- a/src/services/gate-epoch.ts +++ b/src/services/gate-epoch.ts @@ -1,70 +1,13 @@ import { config } from "../config"; -const TTL_MS = 30_000; - -/** - * Passive single-flight cache of the gate's per-doc editGrantEpoch. No timer: entries - * expire on read (fetchedAt + TTL) and concurrent misses coalesce into one fetch. - */ -export class GateEpochCache { - private cache = new Map(); - private inflight = new Map>(); - - constructor(private gateUrl: string | undefined = config.gate.url) {} - - private async rawFetch(documentId: string): Promise { - if (!this.gateUrl) return null; - try { - const res = await fetch(`${this.gateUrl}/gate/doc/${documentId}/edit-grant-epoch`); - if (!res.ok) return null; - const body = (await res.json()) as { editGrantEpoch?: number }; - if (typeof body.editGrantEpoch !== "number") return null; - // editGrantEpoch only ever increases; a slow read that started before a bump must - // not overwrite a fresher cached value with its stale epoch. - const existing = this.cache.get(documentId); - if (!existing || body.editGrantEpoch >= existing.epoch) { - this.cache.set(documentId, { epoch: body.editGrantEpoch, fetchedAt: Date.now() }); - } - return body.editGrantEpoch; - } catch (error) { - console.error("Gate epoch fetch error:", error); - return null; - } - } - - private fetchEpoch(documentId: string): Promise { - if (!this.gateUrl) return Promise.resolve(null); - const existing = this.inflight.get(documentId); - if (existing) return existing; // single-flight: coalesce concurrent misses - const p = this.rawFetch(documentId).finally(() => this.inflight.delete(documentId)); - this.inflight.set(documentId, p); - return p; - } - - /** Cached read (≤TTL, expiry-on-read); zero network on a fresh hit. */ - async getEditGrantEpoch(documentId: string): Promise { - const hit = this.cache.get(documentId); - if (hit && Date.now() - hit.fetchedAt < TTL_MS) return hit.epoch; - return this.fetchEpoch(documentId); - } - - /** Force-drop path: must observe the post-bump epoch, so fetch directly and bypass the - * single-flight coalescing that could hand back a pre-bump in-flight read. */ - async refreshEditGrantEpoch(documentId: string): Promise { - return this.rawFetch(documentId); - } -} - -export const gateEpochCache = new GateEpochCache(); - const EDIT_BOUND_TTL_MS = 10_000; // revocation-latency SLA; targeted evict() makes it near-instant type BoundState = "bound" | "unbound" | "stale-bound" | "cold"; /** * Per-actor fail-closed cache of gate edit-binding (see docs/architecture/gp-semaphore.md). - * Same expiry-on-read + single-flight shape as GateEpochCache, but keyed per (docId, handle) - * and fail-closed on a cold/never-fetched entry instead of fail-open. + * Expiry-on-read + single-flight, keyed per (docId, handle); fail-closed on a + * cold/never-fetched entry. */ export class EditBoundCache { private cache = new Map(); diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index f8acfd9..5bfa89e 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -3,7 +3,7 @@ import type { AppServer } from "../types/index"; import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; -import type { GateEpochCache, EditBoundCache } from "./gate-epoch"; +import type { EditBoundCache } from "./gate-epoch"; import { getRoomName } from "./socket-handlers"; import { Hex, isAddress } from "viem"; import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../utils/contract"; @@ -126,13 +126,16 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) }; } -export function createRefreshEditGrantHandler( - deps: OwnerOpDeps & { gateEpochCache: GateEpochCache }, +/** Targeted eviction of specific gp-actor handles: bust the per-actor edit-bound cache, + * then drop the matching live sockets across every session room of the doc. Co-editors + * with other handles are untouched. */ +export function createEvictEditActorsHandler( + deps: OwnerOpDeps & { editBoundCache: EditBoundCache }, io: AppServer ) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, handles, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { @@ -152,58 +155,27 @@ export function createRefreshEditGrantHandler( return; } - // Post-bump epoch straight from the gate (cache-bypassing), then synchronously drop every - // GP-rail socket admitted below it. Still-authorized editors reconnect and re-mint a fresh - // grant; the demoted one holds only a view/comment share and cannot rejoin as an editor. - // Gate unreachable (null) ⇒ skip the drop; the chokepoint backstop still blocks stale writers. - const currentEpoch = await deps.gateEpochCache.refreshEditGrantEpoch(documentId); - if (currentEpoch !== null) { + const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; + deps.editBoundCache.evict(documentId, list); + + let dropped = 0; + if (list.length > 0) { + const handleSet = new Set(list); const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); const sessionDids = new Set(targets.map((t) => t.sessionDid)); sessionDids.add(sessionDid); for (const sd of sessionDids) { const sockets = await io.in(getRoomName(documentId, sd)).fetchSockets(); for (const s of sockets) { - if (s.data.railKind === "gp-legacy" && (s.data.admittedEditGrantEpoch ?? -1) < currentEpoch) { + if (s.data.railKind === "gp-actor" && s.data.actorHandle && handleSet.has(s.data.actorHandle)) { s.disconnect(true); + dropped++; } } } } - res.status(200).json({ ok: true, editGrantEpoch: currentEpoch }); - }; -} - -/** Targeted eviction of specific gp-actor handles from the per-actor edit-bound cache. - * No socket disconnect: enforcement is the next-write/mirror 403 at the existing - * socket chokepoints (see docs/architecture/gp-semaphore.md), so co-editors are undisturbed. */ -export function createEvictEditActorsHandler(deps: OwnerOpDeps & { editBoundCache: EditBoundCache }) { - return async (req: Request, res: Response): Promise => { - const documentId = req.params.documentId; - const { sessionDid, handles, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; - - const session = await deps.sessionManager.getSession(documentId, sessionDid); - if (!session) { - res.status(404).json({ error: "Session not found" }); - return; - } - - const authorized = await deps.authService.verifyOwnerOp({ - ddocId: documentId, - boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, - boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, - ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, - }); - if (!authorized) { - res.status(403).json({ error: "Not the document owner" }); - return; - } - - const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; - deps.editBoundCache.evict(documentId, list); - res.status(200).json({ ok: true, evicted: list.length }); + res.status(200).json({ ok: true, evicted: list.length, dropped }); }; } diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts index 57a6dc4..31047b1 100644 --- a/src/services/socket-handlers.deps.ts +++ b/src/services/socket-handlers.deps.ts @@ -1,12 +1,11 @@ import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; -import type { GateEpochCache, EditBoundCache } from "./gate-epoch"; +import type { EditBoundCache } from "./gate-epoch"; export interface SocketHandlerDeps { authService: AuthService; sessionManager: SessionManager; mongodbStore: MongoDBStore; - gateEpochCache: GateEpochCache; editBoundCache: EditBoundCache; } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index c2d22e1..22bd40f 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -26,7 +26,7 @@ import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; import { mongodbStore } from "./mongodb-store"; import { sessionManager } from "./session-manager"; -import { gateEpochCache, editBoundCache } from "./gate-epoch"; +import { editBoundCache } from "./gate-epoch"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; import { config } from "../config"; @@ -35,7 +35,6 @@ const defaultDeps: SocketHandlerDeps = { authService, sessionManager, mongodbStore, - gateEpochCache, editBoundCache, }; @@ -50,6 +49,10 @@ export function getRoomName(documentId: string, sessionDid: string): string { return `session::${documentId}__${sessionDid}`; } +// Awareness-path re-check throttle; matches the edit-bound TTL so a revoked-but-idle +// actor is caught within one cache window without adding gate traffic. +const ADMIT_RECHECK_INTERVAL_MS = 10_000; + /** * Coerce an untrusted appType into a known AppType. Anything that is not exactly * "dsheet" becomes "ddoc" (the legacy default), so missing or invalid values @@ -93,7 +96,7 @@ export function registerEventHandlers(io: AppServer): void { handleSetDocumentMeta(defaultDeps, socket, args, callback) ); socket.on("/documents/peers/list", (args, callback) => handlePeersList(io, socket, args, callback)); - socket.on("/documents/awareness", (args) => handleAwareness(io, socket, args)); + socket.on("/documents/awareness", (args) => handleAwareness(defaultDeps, io, socket, args)); socket.on("/documents/terminate", (args, callback) => handleTerminateSession(defaultDeps, io, socket, args, callback) ); @@ -155,8 +158,7 @@ export async function handleAuth( let roomInfo: string | undefined; let resolvedAppType: AppType; let rail: "gp" | "workspace" | "public" | undefined = undefined; - let railKind: "gp-actor" | "gp-legacy" | "workspace" | "public" | undefined = undefined; - let admittedEditGrantEpoch: number | undefined = undefined; + let railKind: "gp-actor" | "workspace" | "public" | undefined = undefined; let actorHandle: string | undefined = undefined; let provenIdentityDid: string | null = null; @@ -453,22 +455,6 @@ export async function handleAuth( errorCode: ErrorCode.JOIN_DISABLED, }); } - } else if (gp?.kind === "legacy") { - // Legacy doc-wide epoch admit (dual-support until 7-day UCAN expiry + full client rollout). - const currentEpoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); - if (currentEpoch === null || gp.editGrantEpoch >= currentEpoch) { - rail = "gp"; - railKind = "gp-legacy"; - admittedEditGrantEpoch = gp.editGrantEpoch; - actorHandle = gp.nullifier; - } else { - return callback({ - status: false, - statusCode: 403, - error: "Edit access is not authorized for this document", - errorCode: ErrorCode.JOIN_DISABLED, - }); - } } else { return callback({ status: false, @@ -536,7 +522,6 @@ export async function handleAuth( socket.data.appType = resolvedAppType; socket.data.rail = rail; socket.data.railKind = railKind; - socket.data.admittedEditGrantEpoch = admittedEditGrantEpoch; socket.data.actorHandle = actorHandle; socket.data.actorIdentityDid = provenIdentityDid ?? undefined; @@ -588,17 +573,13 @@ export async function handleAuth( // Live per-actor edit-admission re-check against the ADMITTED context stamped at JOIN. // Fail-closed for the actor rail (cold/unbound → reject). See docs/architecture/gp-semaphore.md. export async function isStillAdmitted(socket: AppSocket, deps: SocketHandlerDeps): Promise { - const { rail, railKind, documentId, sessionDid, actorHandle, admittedEditGrantEpoch } = socket.data; + const { rail, railKind, documentId, sessionDid, actorHandle } = socket.data; if (!documentId || !sessionDid) return false; if (railKind === "gp-actor") { if (!actorHandle) return false; const s = await deps.editBoundCache.check(documentId, actorHandle); return s === "bound" || s === "stale-bound"; } - if (railKind === "gp-legacy") { - const epoch = await deps.gateEpochCache.getEditGrantEpoch(documentId); - return epoch === null || (admittedEditGrantEpoch ?? -1) >= epoch; - } if (rail === "workspace") return (await deps.sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) === true; return (await deps.sessionManager.getCollabJoinEnabled(documentId, sessionDid)) === true; } @@ -663,12 +644,16 @@ export async function handleDocumentUpdate( if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { const revoked = !(await isStillAdmitted(socket, deps)); if (revoked) { - return callback({ + // Ack the reason first, then drop the socket — the revoked actor must not + // keep presence/read on a session they can no longer write to. + callback({ status: false, statusCode: 403, error: "Edit access has been revoked", errorCode: ErrorCode.EDIT_REVOKED, }); + socket.disconnect(true); + return; } } @@ -1043,7 +1028,9 @@ export async function handleMirrorSnapshot( } // Live per-actor re-check: a demoted editor holding a stale socket must not author a mirror. if (socket.data.role !== "owner" && !(await isStillAdmitted(socket, deps))) { - return callback({ status: false, statusCode: 403, error: "Edit access has been revoked", errorCode: ErrorCode.EDIT_REVOKED }); + callback({ status: false, statusCode: 403, error: "Edit access has been revoked", errorCode: ErrorCode.EDIT_REVOKED }); + socket.disconnect(true); + return; } const { data, fileKeyEpoch } = args; @@ -1181,6 +1168,7 @@ export async function handlePeersList( } export async function handleAwareness( + deps: SocketHandlerDeps, io: AppServer, socket: AppSocket, args: AwarenessArgs, @@ -1197,6 +1185,20 @@ export async function handleAwareness( const roomName = getRoomName(documentId, socket.data.sessionDid); const awarenessPayload = { data, roomId: documentId }; socket.to(roomName).emit("/document/awareness_update", awarenessPayload); + + // 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") { + const now = Date.now(); + if (now - (socket.data.lastAdmitRecheckAt ?? 0) >= ADMIT_RECHECK_INTERVAL_MS) { + socket.data.lastAdmitRecheckAt = now; + void isStillAdmitted(socket, deps) + .then((ok) => { + if (!ok) socket.disconnect(true); + }) + .catch(() => undefined); + } + } } catch (error) { console.error("Error in awareness handler:", error); } diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 6733567..50fe7fa 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -171,11 +171,11 @@ describe("verifyEditUcan", () => { vi.clearAllMocks(); }); - it("returns the facts when verify passes and the fact docId matches", async () => { + it("returns null when the fact carries editGrantEpoch/nullifier but no editHandle (epoch-fact UCANs retired)", async () => { (ucans.verify as any).mockResolvedValue({ ok: true }); (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editGrantEpoch: 3, nullifier: "n-1" }] } }); const svc = new AuthService(serverDid, gateDid); - expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "legacy", editGrantEpoch: 3, nullifier: "n-1" }); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); // rooted at the pinned gate DID + the collab:EDIT capability expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ audience: serverDid, @@ -186,6 +186,13 @@ describe("verifyEditUcan", () => { })); }); + it("returns {kind:'actor', editHandle} when the fact carries a matching docId + editHandle", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editHandle: "h-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "actor", editHandle: "h-1" }); + }); + it("returns null when verify fails (wrong issuer / capability / audience)", async () => { (ucans.verify as any).mockResolvedValue({ ok: false }); const svc = new AuthService(serverDid, gateDid); diff --git a/src/tests/services/gate-epoch.test.ts b/src/tests/services/gate-epoch.test.ts deleted file mode 100644 index 77edee1..0000000 --- a/src/tests/services/gate-epoch.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { GateEpochCache } from "../../services/gate-epoch"; - -describe("GateEpochCache", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("fetches, returns, and caches the editGrantEpoch", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ editGrantEpoch: 7 }) }); - vi.stubGlobal("fetch", fetchMock); - const cache = new GateEpochCache("https://gate.test"); - - expect(await cache.getEditGrantEpoch("doc-1")).toBe(7); - expect(await cache.getEditGrantEpoch("doc-1")).toBe(7); // cache hit — no second fetch - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith("https://gate.test/gate/doc/doc-1/edit-grant-epoch"); - }); - - it("refreshEditGrantEpoch bypasses the cache", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); - vi.stubGlobal("fetch", fetchMock); - const cache = new GateEpochCache("https://gate.test"); - - expect(await cache.getEditGrantEpoch("doc-1")).toBe(1); - expect(await cache.refreshEditGrantEpoch("doc-1")).toBe(2); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("returns null on a non-ok response", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }); - vi.stubGlobal("fetch", fetchMock); - expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); - }); - - it("returns null WITHOUT calling fetch when no gate URL is configured", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - expect(await new GateEpochCache(undefined).getEditGrantEpoch("doc-x")).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("returns null when the response body has a non-number editGrantEpoch", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ editGrantEpoch: "7" }) }); - vi.stubGlobal("fetch", fetchMock); - expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); - }); - - it("returns null when fetch throws", async () => { - const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); - vi.stubGlobal("fetch", fetchMock); - expect(await new GateEpochCache("https://gate.test").getEditGrantEpoch("doc-x")).toBeNull(); - }); - - it("coalesces concurrent misses for the same doc into ONE fetch (single-flight)", async () => { - let resolveFetch!: (v: unknown) => void; - const fetchMock = vi.fn().mockReturnValue(new Promise((r) => (resolveFetch = r))); - vi.stubGlobal("fetch", fetchMock); - const cache = new GateEpochCache("https://gate.test"); - - const p1 = cache.getEditGrantEpoch("doc-1"); - const p2 = cache.getEditGrantEpoch("doc-1"); // second miss before the first resolves - resolveFetch({ ok: true, json: async () => ({ editGrantEpoch: 9 }) }); - - expect(await p1).toBe(9); - expect(await p2).toBe(9); - expect(fetchMock).toHaveBeenCalledTimes(1); // both awaited the same in-flight fetch - }); - - it("refreshEditGrantEpoch does NOT piggyback on an in-flight cache read (observes the post-bump epoch)", async () => { - let resolveFirst!: (v: unknown) => void; - const fetchMock = vi - .fn() - .mockReturnValueOnce(new Promise((r) => (resolveFirst = r))) // in-flight get, pre-bump - .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); // refresh's own fetch, post-bump - vi.stubGlobal("fetch", fetchMock); - const cache = new GateEpochCache("https://gate.test"); - - const getP = cache.getEditGrantEpoch("doc-1"); // starts in-flight fetch #1 - const refreshP = cache.refreshEditGrantEpoch("doc-1"); // must NOT coalesce - resolveFirst({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }); - - expect(await refreshP).toBe(2); // saw the post-bump epoch, not the stale in-flight 1 - expect(await getP).toBe(1); - expect(fetchMock).toHaveBeenCalledTimes(2); // two independent fetches, no coalescing - }); - - it("a stale in-flight read does not clobber a fresher cached epoch (monotonic cache)", async () => { - let resolveStale!: (v: unknown) => void; - const fetchMock = vi - .fn() - .mockReturnValueOnce(new Promise((r) => (resolveStale = r))) // get → resolves to stale 1 - .mockResolvedValueOnce({ ok: true, json: async () => ({ editGrantEpoch: 2 }) }); // refresh → fresh 2 - vi.stubGlobal("fetch", fetchMock); - const cache = new GateEpochCache("https://gate.test"); - - const getP = cache.getEditGrantEpoch("doc-1"); // in-flight, will resolve to 1 - await cache.refreshEditGrantEpoch("doc-1"); // fetches 2, caches 2 - resolveStale({ ok: true, json: async () => ({ editGrantEpoch: 1 }) }); - await getP; // stale 1 resolves; monotonic guard must NOT overwrite cached 2 - - expect(await cache.getEditGrantEpoch("doc-1")).toBe(2); // served from cache, still 2 - expect(fetchMock).toHaveBeenCalledTimes(2); // the cache-hit read did not re-fetch - }); -}); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index be29b7d..c6dce83 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createWorkspaceEditTierHandler } from "../../services/owner-op-routes"; -import { createRefreshEditGrantHandler, createMirrorReadHandler, createEvictWorkspaceMemberHandler } from "../../services/owner-op-routes"; +import { createMirrorReadHandler, createEvictWorkspaceMemberHandler, createEvictEditActorsHandler } from "../../services/owner-op-routes"; import { getRoomName } from "../../services/socket-handlers"; import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../../utils/contract"; @@ -268,104 +268,6 @@ describe("DELETE /documents/:id", () => { }); }); -describe("POST refresh-edit-grant", () => { - let deps: any, io: any, gateEpochCache: any; - beforeEach(() => { - vi.clearAllMocks(); - gateEpochCache = { refreshEditGrantEpoch: vi.fn() }; - deps = { - authService: { verifyOwnerOp: vi.fn() }, - sessionManager: { - getSession: vi.fn().mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }), - getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([{ sessionDid: "s" }]), - }, - gateEpochCache, - }; - io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; - }); - - it("403s when verifyOwnerOp rejects (and never touches the gate)", async () => { - deps.authService.verifyOwnerOp.mockResolvedValue(false); - const r = res(); - await createRefreshEditGrantHandler(deps, io)( - { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r - ); - expect(r.status).toHaveBeenCalledWith(403); - expect(gateEpochCache.refreshEditGrantEpoch).not.toHaveBeenCalled(); - }); - - it("404s when the session is unknown", async () => { - deps.sessionManager.getSession.mockResolvedValue(null); - const r = res(); - await createRefreshEditGrantHandler(deps, io)( - { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r - ); - expect(r.status).toHaveBeenCalledWith(404); - expect(gateEpochCache.refreshEditGrantEpoch).not.toHaveBeenCalled(); - }); - - it("refreshes the epoch and force-drops ONLY stale GP sockets (not fresh GP / workspace / owner)", async () => { - deps.authService.verifyOwnerOp.mockResolvedValue(true); - gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(6); - const staleGp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - const freshGp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 6 }, disconnect: vi.fn() }; - const ws: any = { data: { role: "editor", rail: "workspace", admittedEditGrantEpoch: 0 }, disconnect: vi.fn() }; - const owner: any = { data: { role: "owner" }, disconnect: vi.fn() }; - io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([staleGp, freshGp, ws, owner]) })); - const r = res(); - await createRefreshEditGrantHandler(deps, io)( - { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r - ); - expect(gateEpochCache.refreshEditGrantEpoch).toHaveBeenCalledWith("doc-1"); - expect(staleGp.disconnect).toHaveBeenCalledWith(true); - expect(freshGp.disconnect).not.toHaveBeenCalled(); - expect(ws.disconnect).not.toHaveBeenCalled(); - expect(owner.disconnect).not.toHaveBeenCalled(); - expect(r.status).toHaveBeenCalledWith(200); - expect(r.json).toHaveBeenCalledWith({ ok: true, editGrantEpoch: 6 }); - }); - - it("sweeps EVERY non-terminated session room plus the caller's own (doc-wide)", async () => { - deps.authService.verifyOwnerOp.mockResolvedValue(true); - gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(6); - deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([ - { sessionDid: "s-a" }, - { sessionDid: "s-b" }, - ]); - const gpA: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - const gpCaller: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - const byRoom: Record = { - [getRoomName("doc-1", "s-a")]: [gpA], - [getRoomName("doc-1", "s-b")]: [], - [getRoomName("doc-1", "s-c")]: [gpCaller], // caller's sessionDid, NOT in the non-terminated list - }; - io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); - const r = res(); - await createRefreshEditGrantHandler(deps, io)( - { params: { documentId: "doc-1" }, body: { sessionDid: "s-c" } } as any, r - ); - expect(gpA.disconnect).toHaveBeenCalledWith(true); // a listed session's room was swept - expect(gpCaller.disconnect).toHaveBeenCalledWith(true); // the caller's own room was swept too - expect(io.in).toHaveBeenCalledWith(getRoomName("doc-1", "s-a")); - expect(io.in).toHaveBeenCalledWith(getRoomName("doc-1", "s-c")); - expect(r.status).toHaveBeenCalledWith(200); - }); - - it("does NOT force-drop when the gate epoch is unavailable (null) — the chokepoint backstop covers it", async () => { - deps.authService.verifyOwnerOp.mockResolvedValue(true); - gateEpochCache.refreshEditGrantEpoch.mockResolvedValue(null); - const gp: any = { data: { role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 5 }, disconnect: vi.fn() }; - io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([gp]) })); - const r = res(); - await createRefreshEditGrantHandler(deps, io)( - { params: { documentId: "doc-1" }, body: { sessionDid: "s" } } as any, r - ); - expect(gp.disconnect).not.toHaveBeenCalled(); - expect(r.status).toHaveBeenCalledWith(200); - expect(r.json).toHaveBeenCalledWith({ ok: true, editGrantEpoch: null }); - }); -}); - describe("GET /documents/:id/mirror", () => { it("returns the latest mirror snapshot", async () => { const deps: any = { mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }) } }; @@ -518,3 +420,82 @@ describe("createEvictWorkspaceMemberHandler", () => { expect(getPortalOwnerAddress).not.toHaveBeenCalled(); }); }); + +describe("createEvictEditActorsHandler", () => { + let deps: any, io: any, editBoundCache: any; + beforeEach(() => { + vi.clearAllMocks(); + editBoundCache = { evict: vi.fn() }; + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn().mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([{ sessionDid: "s" }]), + }, + editBoundCache, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("busts the cache and disconnects ONLY matching gp-actor sockets", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const target: any = { data: { role: "editor", railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const otherActor: any = { data: { role: "editor", railKind: "gp-actor", actorHandle: "h2" }, disconnect: vi.fn() }; + const ws: any = { data: { role: "editor", railKind: "workspace", actorHandle: "0xabc" }, disconnect: vi.fn() }; + const owner: any = { data: { role: "owner" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([target, otherActor, ws, owner]) })); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(editBoundCache.evict).toHaveBeenCalledWith("doc-1", ["h1"]); + expect(target.disconnect).toHaveBeenCalledWith(true); + expect(otherActor.disconnect).not.toHaveBeenCalled(); + expect(ws.disconnect).not.toHaveBeenCalled(); + expect(owner.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 1, dropped: 1 }); + }); + + it("sweeps every non-terminated session room of the doc", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([ + { sessionDid: "s-a" }, { sessionDid: "s-b" }, + ]); + const inA: any = { data: { railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const inB: any = { data: { railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const byRoom: Record = { + [getRoomName("doc-1", "s-a")]: [inA], + [getRoomName("doc-1", "s-b")]: [inB], + }; + io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-a", handles: ["h1"] } } as any, r + ); + expect(inA.disconnect).toHaveBeenCalledWith(true); + expect(inB.disconnect).toHaveBeenCalledWith(true); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 1, dropped: 2 }); + }); + + it("skips the sweep entirely for an empty handle list", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: [] } } as any, r + ); + expect(io.in).not.toHaveBeenCalled(); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 0, dropped: 0 }); + }); + + it("403s a non-owner without touching cache or sockets", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(editBoundCache.evict).not.toHaveBeenCalled(); + expect(io.in).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(403); + }); +}); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index 8b667ef..d38d19e 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -64,16 +64,11 @@ describe("handleAuth", () => { updateSessionOwnerDid: vi.fn(), }; const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; - const fakeGateEpochCache = { - getEditGrantEpoch: vi.fn(), - refreshEditGrantEpoch: vi.fn(), - }; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - gateEpochCache: fakeGateEpochCache as any, editBoundCache: { check: vi.fn() } as any, }; @@ -1498,16 +1493,11 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () fillOwnerIdentityDidIfAbsent: vi.fn(), }; const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; - const fakeGateEpochCache = { - getEditGrantEpoch: vi.fn(), - refreshEditGrantEpoch: vi.fn(), - }; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - gateEpochCache: fakeGateEpochCache as any, editBoundCache: { check: vi.fn() } as any, }; @@ -1533,41 +1523,15 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () fakeSessionManager.addClientToSession.mockResolvedValue(true); } - it("admits a GP editor with a valid, fresh editUcan and records rail+epoch+actor", async () => { - existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 5, nullifier: "null-1" }); - (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); - const socket = createFakeSocket(); - const cb = vi.fn(); - await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gate-ucan" }), cb); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); - expect(socket.data.rail).toBe("gp"); - expect(socket.data.admittedEditGrantEpoch).toBe(5); - expect(socket.data.actorHandle).toBe("null-1"); - }); - - it("REJECTS a stale-epoch GP editUcan — no fall-through to public even when join is enabled", async () => { + it("rejects an editUcan that verifies to null (epoch-fact UCANs retired)", async () => { existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 2, nullifier: "null-1" }); - (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(5); // advanced past 2 - fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); // public IS on — must NOT rescue a stale GP grant - const socket = createFakeSocket(); - const cb = vi.fn(); - await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "stale" }), cb); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); - expect(socket.data.rail).toBeUndefined(); // did not fall through to rail=public - }); - - it("admits a valid GP editUcan when the gate epoch is unreachable (getEditGrantEpoch → null) — transient fail-open", async () => { - existingSessionSetup(); - fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "legacy", editGrantEpoch: 3, nullifier: "null-1" }); - (deps as any).gateEpochCache.getEditGrantEpoch.mockResolvedValue(null); // gate transiently down + fakeAuthService.verifyEditUcan.mockResolvedValue(null); const socket = createFakeSocket(); const cb = vi.fn(); - await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gate-ucan" }), cb); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); - expect(socket.data.rail).toBe("gp"); - expect(socket.data.admittedEditGrantEpoch).toBe(3); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "epoch-fact" }), cb); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); }); it("REJECTS an invalid editUcan (verifyEditUcan → null) without falling through", async () => { diff --git a/src/tests/services/socket-handlers.handleAwareness.test.ts b/src/tests/services/socket-handlers.handleAwareness.test.ts index f4c325e..a1965c7 100644 --- a/src/tests/services/socket-handlers.handleAwareness.test.ts +++ b/src/tests/services/socket-handlers.handleAwareness.test.ts @@ -26,9 +26,24 @@ function createFakeSocket( id: "socket-1", data, to: vi.fn(() => op), + disconnect: vi.fn(), } as unknown as AppSocket; } +function createDeps(check = vi.fn().mockResolvedValue("bound")) { + return { + authService: {} as any, + sessionManager: { + getWorkspaceEditEnabled: vi.fn().mockResolvedValue(true), + getCollabJoinEnabled: vi.fn().mockResolvedValue(true), + } as any, + mongodbStore: {} as any, + editBoundCache: { check } as any, + }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + describe('handleAwareness', () => { beforeEach(() => { vi.clearAllMocks(); @@ -43,7 +58,7 @@ describe('handleAwareness', () => { collaborationToken: "", }; - await handleAwareness(fakeIO, fakeSocket, fakeArgs); + await handleAwareness(createDeps() as any, fakeIO, fakeSocket, fakeArgs); expect(fakeSocket.to).not.toHaveBeenCalled(); }); @@ -59,7 +74,7 @@ describe('handleAwareness', () => { collaborationToken: "test-collaborator-token", }; - await handleAwareness(fakeIO, fakeSocket, fakeArgs); + await handleAwareness(createDeps() as any, fakeIO, fakeSocket, fakeArgs); const roomName = `session::${fakeArgs.documentId}__${fakeSocket.data.sessionDid}`; expect(fakeSocket.to).toHaveBeenCalledWith(roomName); @@ -83,6 +98,43 @@ describe('handleAwareness', () => { collaborationToken: "", }; - await expect(handleAwareness(fakeIO, fakeSocket as any, fakeArgs)).resolves.not.toThrow(); + await expect(handleAwareness(createDeps() as any, fakeIO, fakeSocket as any, fakeArgs)).resolves.not.toThrow(); + }); + + it("disconnects a revoked gp-actor editor on awareness traffic", async () => { + const deps = createDeps(vi.fn().mockResolvedValue("unbound")); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect((socket as any).to).toHaveBeenCalled(); // broadcast always goes out + expect((socket as any).disconnect).toHaveBeenCalledWith(true); + }); + + it("never re-checks the owner", async () => { + const check = vi.fn(); + const deps = createDeps(check); + const socket = createFakeSocket(undefined, { role: "owner" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect(check).not.toHaveBeenCalled(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); + }); + + it("throttles: a second call within the interval does not re-check", async () => { + const check = vi.fn().mockResolvedValue("bound"); + const deps = createDeps(check); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("leaves an admitted editor connected", async () => { + const deps = createDeps(vi.fn().mockResolvedValue("bound")); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts index 0c3b226..7bf5155 100644 --- a/src/tests/services/socket-handlers.handleCommitHistory.test.ts +++ b/src/tests/services/socket-handlers.handleCommitHistory.test.ts @@ -43,7 +43,6 @@ describe("commitHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.handleDisconnecting.test.ts b/src/tests/services/socket-handlers.handleDisconnecting.test.ts index 421ac7d..9c20f8e 100644 --- a/src/tests/services/socket-handlers.handleDisconnecting.test.ts +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -46,7 +46,6 @@ describe("handleDisconnecting", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts index c9684c6..a243a53 100644 --- a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts @@ -41,7 +41,6 @@ describe("handleDocumentCommit", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index ec73a85..925d0c5 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -30,6 +30,7 @@ function createFakeSocket( id: "socket-1", data, to: vi.fn(() => toReturn), + disconnect: vi.fn(), } as unknown as AppSocket; } @@ -50,7 +51,6 @@ describe("handleDocumentUpdate", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, - gateEpochCache: { getEditGrantEpoch: vi.fn() } as any, editBoundCache: {} as any, }; @@ -313,32 +313,17 @@ describe("handleDocumentUpdate", () => { describe("handleDocumentUpdate — rail write-guard", () => { function makeEditor( - railData: { rail: "gp" | "workspace" | "public"; admittedEditGrantEpoch?: number }, + railData: { rail: "gp" | "workspace" | "public" }, broadcast?: { emit: ReturnType } ) { const socket = createFakeSocket(broadcast ?? { emit: vi.fn() }, { role: "editor" }); socket.data.rail = railData.rail; - if (railData.rail === "gp") socket.data.railKind = "gp-legacy"; - if (railData.admittedEditGrantEpoch !== undefined) { - socket.data.admittedEditGrantEpoch = railData.admittedEditGrantEpoch; - } fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); return socket; } const args = { documentId: "doc-1", data: "d", collaborationToken: "ct" } as DocumentUpdateArgs; - it("403 EDIT_REVOKED when a GP editor's admitted epoch is now stale — no persist, no broadcast", async () => { - const bcast = { emit: vi.fn() }; - const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 2 }, bcast); - (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(5); - const cb = vi.fn(); - await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); - expect(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); - expect(bcast.emit).not.toHaveBeenCalled(); - }); - it("403 when a workspace editor's tier is now disabled", async () => { const socket = makeEditor({ rail: "workspace" }); fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(false); @@ -360,23 +345,22 @@ describe("handleDocumentUpdate", () => { } }); - it("persists for a GP editor whose epoch is still current", async () => { - const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 5 }); - (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(5); - fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); + it("disconnects the socket after the EDIT_REVOKED 403", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(false); const cb = vi.fn(); await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); - expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect((socket as any).disconnect).toHaveBeenCalledWith(true); }); - it("persists for a GP editor when the gate epoch is unreachable (null — fail-open)", async () => { - const socket = makeEditor({ rail: "gp", admittedEditGrantEpoch: 2 }); - (deps.gateEpochCache as any).getEditGrantEpoch.mockResolvedValue(null); + it("does not disconnect an admitted editor", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); const cb = vi.fn(); await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); - expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); }); diff --git a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts index dcc127e..e6168e2 100644 --- a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts @@ -3,7 +3,11 @@ import { handleMirrorSnapshot } from "../../services/socket-handlers"; import { ErrorCode } from "../../types"; function socket(data: any) { - return { id: "s1", data: { authenticated: true, documentId: "doc-1", sessionDid: "sess-1", ...data } } as any; + return { + id: "s1", + data: { authenticated: true, documentId: "doc-1", sessionDid: "sess-1", ...data }, + disconnect: vi.fn(), + } as any; } describe("handleMirrorSnapshot", () => { @@ -13,7 +17,7 @@ describe("handleMirrorSnapshot", () => { deps = { sessionManager: { getRuntimeSession: vi.fn().mockResolvedValue({ sessionDid: "sess-1" }) }, mongodbStore: { upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined) }, - gateEpochCache: { getEditGrantEpoch: vi.fn().mockResolvedValue(null) }, + editBoundCache: { check: vi.fn() }, }; }); @@ -46,19 +50,31 @@ describe("handleMirrorSnapshot", () => { expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); }); - it("persists for an admitted GP editor", async () => { + it("persists for the owner", async () => { const cb = vi.fn(); - await handleMirrorSnapshot(deps, socket({ role: "editor", rail: "gp", railKind: "gp-legacy", admittedEditGrantEpoch: 1 }), { data: "ct", fileKeyEpoch: 2 } as any, cb); - expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ documentId: "doc-1", data: "ct", fileKeyEpoch: 2, sessionDid: "sess-1", createdAt: expect.any(Number) }) - ); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); }); - it("persists for the owner", async () => { + it("disconnects the socket after the EDIT_REVOKED 403", async () => { + deps.editBoundCache.check.mockResolvedValue("unbound"); const cb = vi.fn(); - await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED }) + ); + expect((s as any).disconnect).toHaveBeenCalledWith(true); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("does not disconnect an admitted editor", async () => { + deps.editBoundCache.check.mockResolvedValue("bound"); + const cb = vi.fn(); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect((s as any).disconnect).not.toHaveBeenCalled(); expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); - expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); }); }); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index 0f83c76..ade004c 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -23,7 +23,6 @@ describe("handleSetDocumentMeta", () => { authService: {} as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { upsertDocumentMeta } as any, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts index 60ff0f3..0aed7e0 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -23,7 +23,6 @@ describe("handleSnapshot", () => { authService: { verifyCollaborationToken } as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { createSnapshot } as any, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index de539ed..ee46de4 100644 --- a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -40,7 +40,6 @@ describe("updateHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index 87e9661..76d915a 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -50,7 +50,6 @@ describe("handleTerminateSession", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - gateEpochCache: {} as any, editBoundCache: {} as any, }; diff --git a/src/types/index.ts b/src/types/index.ts index 7c598d1..c484161 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -327,10 +327,10 @@ export interface SocketData { authenticated: boolean; appType: AppType; rail?: "gp" | "workspace" | "public"; - railKind?: "gp-actor" | "gp-legacy" | "workspace" | "public"; - admittedEditGrantEpoch?: number; + railKind?: "gp-actor" | "workspace" | "public"; actorHandle?: string; actorIdentityDid?: string; + lastAdmitRecheckAt?: number; } // *************************************** From 38f74c1746160081d041876f3fd0dd486ecf39ce Mon Sep 17 00:00:00 2001 From: Nadeem Date: Mon, 20 Jul 2026 15:25:41 +0530 Subject: [PATCH 07/11] feat(share): open share-context existence probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /documents/:documentId/share-context -> { exists, isPublished } lets the app distinguish "created, first publish pending" from a real 404 (the meta row is upserted on owner connect). Open read — same trust model as the mirror GET; the handler try/catches to 500 since Express 4 does not catch async rejections. --- src/index.ts | 5 +++ src/services/mongodb-store.ts | 15 ++++++++ src/services/owner-op-routes.ts | 14 +++++++ src/tests/services/mongodb-store.test.ts | 45 +++++++++++++++++++++- src/tests/services/owner-op-routes.test.ts | 26 +++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 19c1011..69da0bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { createListMyDocumentsHandler, createDeleteDocumentHandler, createMirrorReadHandler, + createShareContextHandler, } from "./services/owner-op-routes"; import { editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; @@ -116,6 +117,10 @@ class CollaborationServer { "/documents/:documentId/mirror", createMirrorReadHandler({ mongodbStore }) ); + this.app.get( + "/documents/:documentId/share-context", + createShareContextHandler({ mongodbStore }) + ); this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); this.app.delete( diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index c80b547..266463b 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -307,6 +307,21 @@ export class MongoDBStore { ); } + /** Open existence probe: lets the client distinguish "created but not yet + * published" (meta row exists from the owner's session) from a real 404. */ + async getShareContext( + documentId: string + ): Promise<{ exists: boolean; isPublished: boolean }> { + const meta: any = await DocumentMetaModel.findById(documentId) + .select("isPublished") + .lean(); + if (!meta) return { exists: false, isPublished: false }; + return { + exists: true, + isPublished: meta.isPublished === true, + }; + } + // Commit management async createCommit(commit: DocumentCommit): Promise { try { diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 5bfa89e..2f8e75d 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -258,6 +258,20 @@ export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { + return async (req: Request, res: Response): Promise => { + // Express 4 doesn't catch async rejections — an unhandled store error + // would hang the request (and the client has no timeout-free fallback). + try { + const ctx = await deps.mongodbStore.getShareContext(req.params.documentId); + res.status(200).json(ctx); + } catch { + res.status(500).json({ error: "share-context lookup failed" }); + } + }; +} + export function createEvictWorkspaceMemberHandler( deps: { authService: Pick; diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index f08d422..4995ace 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -21,7 +21,7 @@ vi.mock("../../database/models", () => { DocumentCommitModel, CounterModel: { findOneAndUpdate: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, SessionModel: { findOne: vi.fn(), find: vi.fn(), deleteMany: vi.fn().mockResolvedValue(undefined) }, - DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), deleteOne: vi.fn().mockResolvedValue(undefined) }, + DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), updateOne: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, DocumentMirrorModel: { findOneAndUpdate: vi.fn().mockResolvedValue(undefined), findOne: vi.fn(), @@ -470,6 +470,49 @@ describe("collectOrphans", () => { }); }); +describe("getShareContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reports existence and isPublished", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.findById as any).mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ isPublished: false }), + }), + }); + + const store = new MongoDBStore(); + expect(await store.getShareContext("doc-sc-none")).toEqual({ + exists: false, isPublished: false, + }); + expect(DocumentMetaModel.findById).toHaveBeenCalledWith("doc-sc-none"); + + expect(await store.getShareContext("doc-sc-2")).toEqual({ + exists: true, isPublished: false, + }); + }); + + it("reports isPublished true only for a strict boolean", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.findById as any).mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn() + .mockResolvedValueOnce({ isPublished: true }) + .mockResolvedValueOnce({}), + }), + }); + + const store = new MongoDBStore(); + expect((await store.getShareContext("doc-sc-pub")).isPublished).toBe(true); + // legacy row with no isPublished field normalizes to false + expect((await store.getShareContext("doc-sc-legacy")).isPublished).toBe(false); + }); +}); + describe("markUpdatesAsCommitted scoping", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index c6dce83..3de2cdb 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createWorkspaceEditTierHandler } from "../../services/owner-op-routes"; import { createMirrorReadHandler, createEvictWorkspaceMemberHandler, createEvictEditActorsHandler } from "../../services/owner-op-routes"; +import { createShareContextHandler } from "../../services/owner-op-routes"; import { getRoomName } from "../../services/socket-handlers"; import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../../utils/contract"; @@ -499,3 +500,28 @@ describe("createEvictEditActorsHandler", () => { expect(r.status).toHaveBeenCalledWith(403); }); }); + +describe("share-context", () => { + it("GET is open and reports existence + isPublished", async () => { + // No auth headers at all — the handler takes no auth deps and reads nothing from req.body. + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: true, isPublished: true }) }, + }; + const r = res(); + await createShareContextHandler(shareDeps)({ params: { documentId: "doc-1" } } as any, r); + expect(shareDeps.mongodbStore.getShareContext).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ exists: true, isPublished: true }); + }); + + it("GET returns exists:false for unknown doc", async () => { + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: false, isPublished: false }) }, + }; + const r = res(); + await createShareContextHandler(shareDeps)({ params: { documentId: "doc-unknown" } } as any, r); + expect(shareDeps.mongodbStore.getShareContext).toHaveBeenCalledWith("doc-unknown"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ exists: false, isPublished: false }); + }); +}); From 28d4d42937078504f552f31df866dc35debf2a7d Mon Sep 17 00:00:00 2001 From: Nadeem Date: Wed, 22 Jul 2026 01:18:52 +0530 Subject: [PATCH 08/11] feat(rotation): session cutover, minEditEpoch enforcement, targeted edit eviction - rotate-route + rotation-coordinator: old->new session cutover with opaque relay payload, liveEditors snapshot, minEditEpoch stamped via $max - documentEditEpoch store; auth rejects editUcan below the doc's minEditEpoch - owner-op routes: evict-edit-actors with epoch stamp; socket handlers gate admission on epoch load --- src/database/models/document-edit-epoch.ts | 20 ++ src/database/models/index.ts | 1 + src/index.ts | 23 +- src/services/auth.ts | 7 +- src/services/flush-route.ts | 38 ++- src/services/mongodb-store.ts | 29 +- src/services/owner-op-routes.ts | 9 +- src/services/rotate-route.ts | 106 +++++++ src/services/rotation-coordinator.ts | 66 +++++ src/services/session-manager.ts | 32 ++ src/services/socket-handlers.ts | 130 ++++++-- src/tests/services/auth.test.ts | 4 +- .../auth.verifyEditUcan.epoch.test.ts | 30 ++ src/tests/services/flush-route.test.ts | 12 + .../mongodb-store.minEditEpoch.test.ts | 43 +++ src/tests/services/mongodb-store.test.ts | 36 ++- src/tests/services/owner-op-routes.test.ts | 43 +++ src/tests/services/rotate-route.test.ts | 280 ++++++++++++++++++ .../services/rotation-coordinator.test.ts | 78 +++++ .../socket-handlers.epochLoaded.test.ts | 66 +++++ .../socket-handlers.handleAuth.test.ts | 214 ++++++++++++- ...cket-handlers.handleDocumentUpdate.test.ts | 28 ++ src/types/index.ts | 23 ++ 23 files changed, 1246 insertions(+), 72 deletions(-) create mode 100644 src/database/models/document-edit-epoch.ts create mode 100644 src/services/rotate-route.ts create mode 100644 src/services/rotation-coordinator.ts create mode 100644 src/tests/services/auth.verifyEditUcan.epoch.test.ts create mode 100644 src/tests/services/mongodb-store.minEditEpoch.test.ts create mode 100644 src/tests/services/rotate-route.test.ts create mode 100644 src/tests/services/rotation-coordinator.test.ts create mode 100644 src/tests/services/socket-handlers.epochLoaded.test.ts diff --git a/src/database/models/document-edit-epoch.ts b/src/database/models/document-edit-epoch.ts new file mode 100644 index 0000000..a2f34a0 --- /dev/null +++ b/src/database/models/document-edit-epoch.ts @@ -0,0 +1,20 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +// Per-document edit-admission floor. A gp-actor editUcan whose `epoch` fact is below this +// is refused at JOIN (offline revocation — replaces the runtime edit-bound poll). Bumped by +// the rotation / evict path. See docs/architecture/gp-semaphore.md. +interface IDocumentEditEpoch extends MongooseDocument { + _id: string; // documentId + minEditEpoch: number; +} + +const DocumentEditEpochSchema = new Schema({ + _id: { type: String, required: true }, + minEditEpoch: { type: Number, required: true, default: 0 }, +}); + +export const DocumentEditEpochModel = mongoose.model( + "DocumentEditEpoch", + DocumentEditEpochSchema +); +export type { IDocumentEditEpoch }; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index cc43795..322c44b 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -4,3 +4,4 @@ export { SessionModel, type ISession } from "./session"; export { CounterModel, type ICounter } from "./counter"; export { DocumentMetaModel, type IDocumentMeta } from "./document-meta"; export { DocumentMirrorModel, type IDocumentMirror } from "./document-mirror"; +export { DocumentEditEpochModel, type IDocumentEditEpoch } from "./document-edit-epoch"; diff --git a/src/index.ts b/src/index.ts index 69da0bf..d37ee9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import compression from "compression"; import { createServer } from "http"; import { config } from "./config"; import { authService } from "./services/auth"; -import { registerEventHandlers } from "./services/socket-handlers"; +import { registerEventHandlers, getRoomName } from "./services/socket-handlers"; import { authMiddleware } from "./services/auth-middleware"; import { sessionManager } from "./services/session-manager"; import { mongodbStore } from "./services/mongodb-store"; @@ -23,6 +23,8 @@ import { createMirrorReadHandler, createShareContextHandler, } from "./services/owner-op-routes"; +import { createRotateSessionHandler } from "./services/rotate-route"; +import { rotationCoordinator } from "./services/rotation-coordinator"; import { editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; import { createLightNode } from "@waku/sdk"; @@ -107,12 +109,29 @@ class CollaborationServer { ); this.app.post( "/documents/:documentId/evict-edit-actors", - createEvictEditActorsHandler({ authService, sessionManager, editBoundCache }, this.io) + createEvictEditActorsHandler({ authService, sessionManager, editBoundCache, mongodbStore }, this.io) ); this.app.post( "/workspaces/:portalAddress/evict-member", createEvictWorkspaceMemberHandler({ authService, sessionManager }, this.io) ); + this.app.post( + "/documents/:documentId/rotate-session", + createRotateSessionHandler({ + authService, sessionManager, mongodbStore, rotationCoordinator, + terminateOldSession: async (documentId, sessionDid, appType) => { + 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 + // D-11 SESSION_TERMINATED 409 — that ack is their self-heal signal. + // De-authing here would surface a 401 first and strand them frozen. + // 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); + }, + }, this.io) + ); this.app.get( "/documents/:documentId/mirror", createMirrorReadHandler({ mongodbStore }) diff --git a/src/services/auth.ts b/src/services/auth.ts index c355b69..78af300 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -105,7 +105,7 @@ export class AuthService { async verifyEditUcan( token: string, documentId: string - ): Promise<{ kind: "actor"; editHandle: string } | null> { + ): Promise<{ kind: "actor"; editHandle: string; epoch: number } | null> { if (!this.gateDid) return null; // GP editing disabled until GATE_DID is pinned try { const result = await ucans.verify(token, { @@ -126,9 +126,12 @@ export class AuthService { const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { docId?: string; editHandle?: string; + epoch?: number; }; if (fact.docId !== documentId) return null; - if (typeof fact.editHandle === "string") return { kind: "actor", editHandle: fact.editHandle }; + if (typeof fact.editHandle === "string") { + return { kind: "actor", editHandle: fact.editHandle, epoch: typeof fact.epoch === "number" ? fact.epoch : 0 }; + } return null; } catch (error) { console.error("Edit UCAN verification error:", error); diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index 2b02e51..afc5b58 100644 --- a/src/services/flush-route.ts +++ b/src/services/flush-route.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import type { AuthService } from "./auth"; -import type { MongoDBStore } from "./mongodb-store"; +import { SessionTerminatedError, type MongoDBStore } from "./mongodb-store"; export const FLUSH_MAX_BYTES = 2 * 1024 * 1024; // a final delta is small; cap well under the 10MB body limit @@ -28,20 +28,28 @@ export function createFlushHandler(deps: FlushDeps) { return; } - // Same chokepoint as the socket path: createUpdate applies seq + the durable-write gate; a row is - // persisted only for a bound, non-terminated room. Zero-knowledge — the server never reads `data`. - await deps.mongodbStore.createUpdate({ - id: uuidv4(), - documentId, - data, - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt: Date.now(), - sessionDid, - appType: "ddoc", - }); - + // Per-actor edit admission runs at socket JOIN; this durable-write path additionally + // refuses a terminated (rotated-away) session so a lingering old-session client cannot + // persist. Zero-knowledge — the server never reads `data`. + try { + await deps.mongodbStore.createUpdate({ + id: uuidv4(), + documentId, + data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: Date.now(), + sessionDid, + appType: "ddoc", + }); + } catch (err) { + if (err instanceof SessionTerminatedError) { + res.status(409).json({ error: "Session terminated" }); + return; + } + throw err; + } res.status(200).json({ ok: true }); }; } diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index 266463b..e101cb8 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,5 +1,9 @@ import { DocumentUpdate, DocumentCommit } from "../types/index"; -import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel } from "../database/models"; +import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel, DocumentEditEpochModel } from "../database/models"; + +export class SessionTerminatedError extends Error { + constructor() { super("session terminated"); this.name = "SessionTerminatedError"; } +} export class MongoDBStore { // Update management @@ -9,7 +13,10 @@ export class MongoDBStore { documentId: update.documentId, sessionDid: update.sessionDid, }); - if (!session || session.state === "terminated") { + if (session?.state === "terminated") { + throw new SessionTerminatedError(); + } + if (!session) { return update; // ephemeral relay — no durable row, no seq burned } @@ -36,7 +43,9 @@ export class MongoDBStore { await mongoUpdate.save(); return { ...update, seq }; } catch (error) { - console.error("Error creating update:", error); + if (!(error instanceof SessionTerminatedError)) { + console.error("Error creating update:", error); + } throw error; } } @@ -264,6 +273,19 @@ export class MongoDBStore { return { editLock: meta.editLock ?? null, title: meta.title ?? null }; } + async setMinEditEpoch(documentId: string, epoch: number): Promise { + await DocumentEditEpochModel.findOneAndUpdate( + { _id: documentId }, + { $max: { minEditEpoch: epoch } }, + { upsert: true } + ); + } + + async getMinEditEpoch(documentId: string): Promise { + const row: any = await DocumentEditEpochModel.findById(documentId).lean(); + return row?.minEditEpoch ?? 0; + } + // 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 } @@ -463,6 +485,7 @@ export class MongoDBStore { SessionModel.deleteMany({ documentId }), CounterModel.deleteOne({ _id: documentId }), DocumentMirrorModel.deleteMany({ documentId }), + DocumentEditEpochModel.deleteOne({ _id: documentId }), ]); } diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 2f8e75d..c433971 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -130,7 +130,7 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) * then drop the matching live sockets across every session room of the doc. Co-editors * with other handles are untouched. */ export function createEvictEditActorsHandler( - deps: OwnerOpDeps & { editBoundCache: EditBoundCache }, + deps: OwnerOpDeps & { editBoundCache: EditBoundCache; mongodbStore: Pick }, io: AppServer ) { return async (req: Request, res: Response): Promise => { @@ -155,6 +155,13 @@ export function createEvictEditActorsHandler( return; } + // Stamp the floor even when rotation is deferred, so a stale-UCAN rejoin is blocked + // immediately rather than waiting on the next epoch bump to propagate. gateEpoch is + // optional here — an invalid value is silently skipped rather than failing the evict. + if (Number.isInteger(req.body?.gateEpoch) && req.body.gateEpoch >= 0) { + await deps.mongodbStore.setMinEditEpoch(documentId, req.body.gateEpoch); + } + const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; deps.editBoundCache.evict(documentId, list); diff --git a/src/services/rotate-route.ts b/src/services/rotate-route.ts new file mode 100644 index 0000000..2d05b38 --- /dev/null +++ b/src/services/rotate-route.ts @@ -0,0 +1,106 @@ +import type { Request, Response } from "express"; +import type { AppServer } from "../types/index"; +import type { AuthService } from "./auth"; +import type { SessionManager } from "./session-manager"; +import type { MongoDBStore } from "./mongodb-store"; +import type { RotationCoordinator } from "./rotation-coordinator"; +import { getRoomName } from "./socket-handlers"; +import { Hex } from "viem"; + +const T_DRAIN_MS = 10_000; + +export interface RotateDeps { + authService: Pick; + sessionManager: Pick; + mongodbStore: Pick; + rotationCoordinator: Pick; + terminateOldSession: (documentId: string, sessionDid: string, appType: "ddoc" | "dsheet") => Promise; +} + +export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { + oldSessionDid, newSessionDid, payload, gateEpoch, + identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress, + } = req.body || {}; + + if ( + typeof oldSessionDid !== "string" || + typeof newSessionDid !== "string" || + typeof payload !== "string" || + !Number.isInteger(gateEpoch) || + gateEpoch < 0 + ) { + res.status(400).json({ error: "oldSessionDid, newSessionDid, payload and a non-negative integer gateEpoch are required" }); + return; + } + + // One rotation per doc at a time; the gate's epoch CAS is the cross-device backstop. + // See docs/architecture/gp-semaphore.md. + if (deps.rotationCoordinator.isActive(documentId)) { + res.status(409).json({ error: "Rotation already in progress" }); + return; + } + + const oldSession = await deps.sessionManager.getSession(documentId, oldSessionDid); + if (!oldSession) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (oldSession as any).ownerIdentityDid ?? null, + boundOwnerDid: oldSession.ownerDid ?? null, + identityToken, identityContractAddress: identityContractAddress as Hex, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Create the post-rotation session WITHOUT the owner-/auth takeover sweep, so the old + // session stays live through the make-before-break cutover. Idempotent on retry. + if (!(await deps.sessionManager.getSession(documentId, newSessionDid))) { + await deps.sessionManager.createSession({ + documentId, + sessionDid: newSessionDid, + ownerDid: oldSession.ownerDid, + ownerIdentityDid: (oldSession as any).ownerIdentityDid ?? null, + portalAddress: oldSession.portalAddress ?? undefined, + collabJoinEnabled: (oldSession as any).collabJoinEnabled ?? false, + roomInfo: oldSession.roomInfo, + appType: oldSession.appType ?? "ddoc", + } as any); + } + + await deps.mongodbStore.setMinEditEpoch(documentId, gateEpoch); + + const oldRoom = getRoomName(documentId, oldSessionDid); + io.to(oldRoom).emit("/session/epoch_available", { roomId: documentId, epoch: gateEpoch, payload }); + + // Admitted editors always have a rail set at JOIN; a role-capped read-only socket + // (e.g. the owner's headless joinOnly read) is not, and must not stall the barrier. + const expected = (await io.in(oldRoom).fetchSockets()) + .filter((s) => s.data.role !== "owner" && !!s.data.rail) + .map((s) => s.id); + + deps.rotationCoordinator.begin({ + documentId, + oldSessionDid, + epoch: gateEpoch, + expected, + onCutover: () => { + io.to(oldRoom).emit("/session/cutover", { roomId: documentId, epoch: gateEpoch }); + setTimeout(() => { + void deps.terminateOldSession(documentId, oldSessionDid, oldSession.appType ?? "ddoc") + .catch((e) => console.error("rotate terminate error:", e)); + }, T_DRAIN_MS); + }, + }); + + res.status(200).json({ ok: true, newSessionDid, liveEditors: expected.length }); + }; +} diff --git a/src/services/rotation-coordinator.ts b/src/services/rotation-coordinator.ts new file mode 100644 index 0000000..9882adc --- /dev/null +++ b/src/services/rotation-coordinator.ts @@ -0,0 +1,66 @@ +interface Barrier { + epoch: number; + expected: Set; + acked: Set; + timer: ReturnType | null; + fired: boolean; + onCutover: () => void; +} + +// Best-effort ack barrier for a seamless cutover. Single-instance accurate; under the Redis +// adapter an ack may land on another instance, so correctness rests on the timeout + the +// client decrypt-miss self-heal, never on ack completeness. See docs/architecture/gp-semaphore.md. +export class RotationCoordinator { + private barriers = new Map(); + constructor(private tBarrierMs: number = 4000) {} + + begin(args: { + documentId: string; + oldSessionDid: string; + epoch: number; + expected: string[]; + onCutover: () => void; + }): void { + this.finish(args.documentId); // supersede any prior barrier for this doc + const b: Barrier = { + epoch: args.epoch, + expected: new Set(args.expected), + acked: new Set(), + timer: null, + fired: false, + onCutover: args.onCutover, + }; + this.barriers.set(args.documentId, b); + if (b.expected.size === 0) return this.fire(args.documentId, b); + b.timer = setTimeout(() => this.fire(args.documentId, b), this.tBarrierMs); + } + + recordAck(documentId: string, socketId: string): void { + const b = this.barriers.get(documentId); + if (!b || b.fired) return; + if (!b.expected.has(socketId)) return; + b.acked.add(socketId); + if (b.acked.size >= b.expected.size) this.fire(documentId, b); + } + + isActive(documentId: string): boolean { + const b = this.barriers.get(documentId); + return !!b && !b.fired; + } + + finish(documentId: string): void { + const b = this.barriers.get(documentId); + if (b?.timer) clearTimeout(b.timer); + this.barriers.delete(documentId); + } + + private fire(documentId: string, b: Barrier): void { + if (b.fired) return; + b.fired = true; + if (b.timer) clearTimeout(b.timer); + b.onCutover(); + this.barriers.delete(documentId); + } +} + +export const rotationCoordinator = new RotationCoordinator(); diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 260f059..c399f7c 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -159,6 +159,38 @@ export class SessionManager { return runtimeSession; } + // Terminated-inclusive lookup: same as getSession minus the state filter, for the + // joinOnly headless read path — termination blocks writes only (state-based, enforced + // independently in createUpdate), so a rotated-away session's durable rows must still + // be readable. Never memoized into inMemorySessions: doing so would let a later plain + // getSession() resurrect a terminated session for non-joinOnly admission. + async getSessionIncludingTerminated( + documentId: string, + sessionDid: string + ): Promise { + const sessionKey = this.getSessionKey(documentId, sessionDid); + const inMemorySession = this.inMemorySessions.get(sessionKey); + if (inMemorySession) { + return inMemorySession; + } + + const dbSession = await SessionModel.findOne({ documentId, sessionDid }); + if (!dbSession) return undefined; + + return { + documentId: dbSession.documentId, + sessionDid: dbSession.sessionDid, + ownerDid: dbSession.ownerDid, + clients: new Set(), + roomInfo: dbSession.roomInfo, + appType: dbSession.appType, + ownerIdentityDid: dbSession.ownerIdentityDid ?? undefined, + portalAddress: dbSession.portalAddress ?? undefined, + collabJoinEnabled: dbSession.collabJoinEnabled, + workspaceEditEnabled: dbSession.workspaceEditEnabled, + }; + } + async getCollabJoinEnabled(documentId: string, sessionDid: string): Promise { const doc = await SessionModel.findOne({ documentId, sessionDid }, { collabJoinEnabled: 1 }).lean(); return (doc as any)?.collabJoinEnabled; diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 22bd40f..61b3d27 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -16,6 +16,7 @@ import { PeersListArgs, AwarenessArgs, TerminateSessionArgs, + EpochLoadedArgs, DocumentCommit, AppServer, AppSocket, @@ -24,9 +25,10 @@ import { } from "../types/index"; import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; -import { mongodbStore } from "./mongodb-store"; +import { mongodbStore, SessionTerminatedError } from "./mongodb-store"; import { sessionManager } from "./session-manager"; import { editBoundCache } from "./gate-epoch"; +import { rotationCoordinator } from "./rotation-coordinator"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; import { config } from "../config"; @@ -100,6 +102,7 @@ export function registerEventHandlers(io: AppServer): void { socket.on("/documents/terminate", (args, callback) => handleTerminateSession(defaultDeps, io, socket, args, callback) ); + socket.on("/session/epoch_loaded", (args, callback) => handleEpochLoaded(socket, args, callback)); // Disconnection handling socket.on("disconnecting", () => handleDisconnecting(defaultDeps, socket)); @@ -151,7 +154,7 @@ export async function handleAuth( }); } - const existingSession = await sessionManager.getSession(documentId, sessionDid); + let existingSession = await sessionManager.getSession(documentId, sessionDid); let role: "owner" | "editor"; let sessionType: "new" | "existing"; @@ -167,12 +170,18 @@ export async function handleAuth( // session and bind THEIR identity as the room's root of trust (member-first-bind). // Distinct code so clients can render read-only-until-creator-opens. if (!existingSession && args.joinOnly === true) { - return callback({ - status: false, - statusCode: 404, - error: "Room not established", - errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, - }); + // Termination blocks WRITES only (state-based, enforced independently in + // createUpdate) — a rotated-away session's durable rows are still readable, so + // retry terminated-inclusive before giving up. See docs/architecture/gp-semaphore.md. + existingSession = await sessionManager.getSessionIncludingTerminated(documentId, sessionDid); + if (!existingSession) { + return callback({ + status: false, + statusCode: 404, + error: "Room not established", + errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, + }); + } } if (!existingSession && args.ownerToken) { @@ -401,6 +410,11 @@ export async function handleAuth( role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; } + // Captured pre-cap: whatever role resolution (above) already decided is "owner" — + // the same credentials would get full owner rights via the plain (non-joinOnly) + // path, so a joinOnly read for them is strictly privilege-reducing, never a new grant. + const ownerVerifiedPreCap = role === "owner"; + // joinOnly caps the role: on an unbound/legacy session the shared-DID compare // resolves a team member to owner — a join-only bearer never gets owner powers. if (args.joinOnly === true && role === "owner") role = "editor"; @@ -436,10 +450,25 @@ export async function handleAuth( // 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. - if (storedAppType === "ddoc" && role === "editor") { + // + // Owner-verified joinOnly bypasses this gate entirely (admission only — role stays + // capped to "editor" above): the gate exists to admit non-owner editors onto a grant + // 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 (args.editUcan) { const gp = await authService.verifyEditUcan(args.editUcan, documentId); if (gp?.kind === "actor") { + const minEpoch = await deps.mongodbStore.getMinEditEpoch(documentId); + if (gp.epoch < minEpoch) { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } // Per-actor positive-state admission (keyed on editHandle = hash(C, docId)); fail-closed on // cold/unbound. See docs/architecture/gp-semaphore.md. const state = await deps.editBoundCache.check(documentId, gp.editHandle); @@ -514,6 +543,10 @@ export async function handleAuth( }); } + // In-place re-auth on rotation cutover: capture the pre-rotation room membership + // BEFORE it's overwritten below, so the socket can be silently migrated off it. + const priorSessionDid = socket.data.sessionDid; + // Set socket data socket.data.authenticated = true; socket.data.documentId = documentId; @@ -527,6 +560,15 @@ export async function handleAuth( // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); + + if (args.rotationCutover && priorSessionDid && priorSessionDid !== sessionDid) { + // Silent migration — no user_left. The socket never disconnects, so the + // `disconnecting` sweep never fires for the pre-rotation sessionDid; do that + // cleanup here instead to keep the old session's client list honest. + socket.leave(getRoomName(documentId, priorSessionDid)); + await sessionManager.removeClientFromSession(documentId, priorSessionDid, socket.id); + } + socket.join(roomName); // Track in session manager (for session lifecycle / deactivation logic) @@ -534,13 +576,16 @@ export async function handleAuth( console.log(sessionType === "new" ? "SETUP DONE" : "JOINED SESSION", documentId, role); - // Broadcast membership change to others in the room - const membershipPayload = { - action: "user_joined" as const, - user: { role }, - roomId: documentId, - }; - socket.to(roomName).emit("/room/membership_change", membershipPayload); + // Broadcast membership change to others in the room — suppressed on a rotation + // cutover re-auth so the surviving socket doesn't blip presence for itself. + if (!args.rotationCutover) { + const membershipPayload = { + action: "user_joined" as const, + user: { role }, + roomId: documentId, + }; + socket.to(roomName).emit("/room/membership_change", membershipPayload); + } // Latest stored title beats the session-frozen roomInfo blob for joiners // arriving after a mid-session rename. @@ -662,17 +707,31 @@ export async function handleDocumentUpdate( // 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, - data, - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt, - sessionDid, - appType: socket.data.appType, - }); + let update; + try { + update = await mongodbStore.createUpdate({ + id: updateId, + documentId, + data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt, + sessionDid, + appType: socket.data.appType, + }); + } catch (err) { + if (err instanceof SessionTerminatedError) { + callback({ + status: false, + statusCode: 409, + error: "Session terminated", + errorCode: ErrorCode.SESSION_TERMINATED, + }); + return; + } + throw err; + } const roomName = getRoomName(documentId, socket.data.sessionDid); socket.to(roomName).emit("/document/content_update", { @@ -1309,6 +1368,23 @@ export async function handleTerminateSession( } } +export function handleEpochLoaded( + socket: AppSocket, + args: EpochLoadedArgs, + callback: (r: AckResponse<{ ok: true }>) => void +): void { + if (!requireAuth(socket)) { + return callback({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + } + rotationCoordinator.recordAck(args.documentId || socket.data.documentId, socket.id); + callback({ status: true, statusCode: 200, data: { ok: true } }); +} + export async function handleDisconnecting( deps: SocketHandlerDeps, socket: AppSocket diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 50fe7fa..8b84fe6 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -186,11 +186,11 @@ describe("verifyEditUcan", () => { })); }); - it("returns {kind:'actor', editHandle} when the fact carries a matching docId + editHandle", async () => { + it("returns {kind:'actor', editHandle, epoch:0} when the fact carries a matching docId + editHandle but no epoch", async () => { (ucans.verify as any).mockResolvedValue({ ok: true }); (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editHandle: "h-1" }] } }); const svc = new AuthService(serverDid, gateDid); - expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "actor", editHandle: "h-1" }); + expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "actor", editHandle: "h-1", epoch: 0 }); }); it("returns null when verify fails (wrong issuer / capability / audience)", async () => { diff --git a/src/tests/services/auth.verifyEditUcan.epoch.test.ts b/src/tests/services/auth.verifyEditUcan.epoch.test.ts new file mode 100644 index 0000000..bc59a48 --- /dev/null +++ b/src/tests/services/auth.verifyEditUcan.epoch.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import * as ucans from "@ucans/ucans"; +import { AuthService } from "../../services/auth"; + +async function mint(gate: ucans.EdKeypair, serverDid: string, facts: Record[], docId: string) { + const u = await ucans.build({ + issuer: gate, audience: serverDid, + capabilities: [{ with: { scheme: "collab", hierPart: docId }, can: { namespace: "collab", segments: ["EDIT"] } }], + facts, lifetimeInSeconds: 3600, + }); + return ucans.encode(u); +} + +describe("verifyEditUcan epoch fact", () => { + it("returns the epoch from the fact", async () => { + const gate = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did(), gate.did()); + const token = await mint(gate, server.did(), [{ docId: "doc-1", editHandle: "h1", epoch: 3 }], "doc-1"); + expect(await svc.verifyEditUcan(token, "doc-1")).toEqual({ kind: "actor", editHandle: "h1", epoch: 3 }); + }); + + it("defaults epoch to 0 when the fact omits it (pre-Plan-1 UCAN)", async () => { + const gate = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did(), gate.did()); + const token = await mint(gate, server.did(), [{ docId: "doc-1", editHandle: "h1" }], "doc-1"); + expect(await svc.verifyEditUcan(token, "doc-1")).toEqual({ kind: "actor", editHandle: "h1", epoch: 0 }); + }); +}); diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts index e8f8ab9..61e0860 100644 --- a/src/tests/services/flush-route.test.ts +++ b/src/tests/services/flush-route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createFlushHandler, FLUSH_MAX_BYTES } from "../../services/flush-route"; +import { SessionTerminatedError } from "../../services/mongodb-store"; function res() { const r: any = {}; @@ -44,4 +45,15 @@ describe("POST /flush", () => { })); expect(r.status).toHaveBeenCalledWith(200); }); + + it("409s with an error body (not 200) when createUpdate rejects with SessionTerminatedError", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + deps.mongodbStore.createUpdate.mockRejectedValue(new SessionTerminatedError()); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct" } } as any, r); + + expect(r.status).toHaveBeenCalledWith(409); + expect(r.status).not.toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) })); + }); }); diff --git a/src/tests/services/mongodb-store.minEditEpoch.test.ts b/src/tests/services/mongodb-store.minEditEpoch.test.ts new file mode 100644 index 0000000..d78cf4a --- /dev/null +++ b/src/tests/services/mongodb-store.minEditEpoch.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findOneAndUpdate = vi.fn(); +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentEditEpochModel: { + findOneAndUpdate: (...a: unknown[]) => findOneAndUpdate(...a), + findById: (...a: unknown[]) => findById(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMetaModel: {}, DocumentMirrorModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("minEditEpoch store", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("setMinEditEpoch upserts with a monotonic $max", async () => { + findOneAndUpdate.mockResolvedValue({}); + await store.setMinEditEpoch("doc-1", 5); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "doc-1" }, + { $max: { minEditEpoch: 5 } }, + { upsert: true } + ); + }); + + it("getMinEditEpoch returns the stored value", async () => { + findById.mockReturnValue({ lean: () => Promise.resolve({ minEditEpoch: 7 }) }); + expect(await store.getMinEditEpoch("doc-1")).toBe(7); + }); + + it("getMinEditEpoch returns 0 when the doc has no floor yet", async () => { + findById.mockReturnValue({ lean: () => Promise.resolve(null) }); + expect(await store.getMinEditEpoch("doc-x")).toBe(0); + }); +}); diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 4995ace..39b38dd 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { DocumentUpdateModel } from "../../database/models/document-update"; import { CounterModel } from "../../database/models/counter"; -import { MongoDBStore } from "../../services/mongodb-store"; +import { MongoDBStore, SessionTerminatedError } from "../../services/mongodb-store"; vi.mock("../../database/models", () => { const save = vi.fn().mockResolvedValue(undefined); @@ -27,6 +27,7 @@ vi.mock("../../database/models", () => { findOne: vi.fn(), deleteMany: vi.fn().mockResolvedValue(undefined), }, + DocumentEditEpochModel: { deleteOne: vi.fn().mockResolvedValue(undefined) }, }; }); @@ -64,19 +65,22 @@ describe("createUpdate: durable-write gate", () => { (SessionModel.findOne as any).mockResolvedValue({ state: "active", ownerDid: "did:o" }); }); - it("relays without persisting when no non-terminated session backs the room", async () => { - const { SessionModel, DocumentUpdateModel } = await import("../../database/models"); + it("relays without persisting when no session row backs the room (missing row is NOT terminated — D-11 is terminated-only)", async () => { + const { CounterModel, SessionModel, DocumentUpdateModel } = await import("../../database/models"); (SessionModel.findOne as any).mockResolvedValue(null); const save = vi.fn(); (DocumentUpdateModel as any).mockImplementation((doc: any) => ({ ...doc, save })); const store = new MongoDBStore(); - const result = await store.createUpdate({ - id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", - committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", - }); + const input = { + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update" as const, + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc" as const, + }; + const result = await store.createUpdate(input); + expect(result).toEqual(input); expect(save).not.toHaveBeenCalled(); + expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); expect(result.seq).toBeUndefined(); }); @@ -89,18 +93,19 @@ describe("createUpdate: durable-write gate", () => { expect(result.seq).toBe(1); }); - it("relays without persisting when the backing session is terminated", async () => { + it("rejects with SessionTerminatedError when the backing session is terminated (D-11: terminated-only, no ephemeral relay)", async () => { const { CounterModel, SessionModel } = await import("../../database/models"); (SessionModel.findOne as any).mockResolvedValue({ state: "terminated" }); const store = new MongoDBStore(); - const result = await store.createUpdate({ - id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", - committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", - }); + await expect( + store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", + }) + ).rejects.toThrow(SessionTerminatedError); expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); - expect(result.seq).toBeUndefined(); }); }); @@ -408,8 +413,8 @@ describe("purgeDocument", () => { vi.clearAllMocks(); }); - it("wipes all six collections for the documentId", async () => { - const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel } = + it("wipes all seven collections for the documentId", async () => { + const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel, DocumentEditEpochModel } = await import("../../database/models"); const store = new MongoDBStore(); @@ -421,6 +426,7 @@ describe("purgeDocument", () => { expect(SessionModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); expect(CounterModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); expect(DocumentMirrorModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(DocumentEditEpochModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); }); }); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 3de2cdb..83e1bc2 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -434,6 +434,7 @@ describe("createEvictEditActorsHandler", () => { getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([{ sessionDid: "s" }]), }, editBoundCache, + mongodbStore: { setMinEditEpoch: vi.fn() }, }; io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; }); @@ -499,6 +500,48 @@ describe("createEvictEditActorsHandler", () => { expect(io.in).not.toHaveBeenCalled(); expect(r.status).toHaveBeenCalledWith(403); }); + + it("stamps minEditEpoch when the client sends a gateEpoch", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: 4 } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).toHaveBeenCalledWith("doc-1", 4); + }); + + it("does not stamp minEditEpoch when gateEpoch is absent", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + }); + + it("does not stamp minEditEpoch when gateEpoch is NaN, but the evict still proceeds", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: NaN } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + expect(editBoundCache.evict).toHaveBeenCalledWith("doc-1", ["h1"]); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("does not stamp minEditEpoch when gateEpoch is negative or non-integer", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + for (const badEpoch of [-1, 1.5]) { + deps.mongodbStore.setMinEditEpoch.mockClear(); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: badEpoch } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + } + }); }); describe("share-context", () => { diff --git a/src/tests/services/rotate-route.test.ts b/src/tests/services/rotate-route.test.ts new file mode 100644 index 0000000..bbac232 --- /dev/null +++ b/src/tests/services/rotate-route.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRotateSessionHandler } from "../../services/rotate-route"; +import { getRoomName } from "../../services/socket-handlers"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +describe("POST /documents/:id/rotate-session", () => { + const documentId = "doc-1"; + const oldSessionDid = "s-old"; + const newSessionDid = "s-new"; + const oldRoom = getRoomName(documentId, oldSessionDid); + + let deps: any, io: any, ownerSock: any, editorSockA: any, editorSockB: any; + + beforeEach(() => { + vi.clearAllMocks(); + ownerSock = { id: "sock-owner", data: { role: "owner" } }; + editorSockA = { id: "sock-a", data: { role: "editor", rail: "public" } }; + editorSockB = { id: "sock-b", data: { role: "editor", rail: "workspace" } }; + + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn(), + createSession: vi.fn(), + }, + mongodbStore: { setMinEditEpoch: vi.fn() }, + rotationCoordinator: { + isActive: vi.fn().mockReturnValue(false), + begin: vi.fn(), + }, + terminateOldSession: vi.fn().mockResolvedValue(undefined), + }; + + io = { + to: vi.fn(() => ({ emit: vi.fn() })), + in: vi.fn(() => ({ + fetchSockets: vi.fn().mockResolvedValue([ownerSock, editorSockA, editorSockB]), + })), + }; + }); + + function body(overrides: Record = {}) { + return { + oldSessionDid, + newSessionDid, + payload: "opaque-ct", + gateEpoch: 5, + identityToken: "it", + identityContractAddress: "0xI", + ownerToken: "ot", + ownerAddress: "0xO", + portalAddress: "0xP", + ...overrides, + }; + } + + it("403s when verifyOwnerOp rejects", async () => { + deps.sessionManager.getSession.mockResolvedValue({ + sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", + }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + }); + + it("409s when a rotation is already active for the doc — no broadcast or createSession", async () => { + deps.rotationCoordinator.isActive.mockReturnValue(true); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(409); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + expect(io.to).not.toHaveBeenCalled(); + expect(deps.rotationCoordinator.begin).not.toHaveBeenCalled(); + }); + + it("404s when the old session is not found", async () => { + deps.sessionManager.getSession.mockResolvedValue(undefined); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(404); + expect(deps.authService.verifyOwnerOp).not.toHaveBeenCalled(); + }); + + it("400s when required fields are missing", async () => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: { oldSessionDid } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it("400s when oldSessionDid is missing", async () => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body({ oldSessionDid: undefined }) } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it.each([NaN, -1, 1.5])("400s when gateEpoch is invalid (%s)", async (badEpoch) => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body({ gateEpoch: badEpoch }) } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it("on success: creates the new session, stamps minEditEpoch, broadcasts epoch_available, begins the barrier with non-owner sockets, and responds 200", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, + sessionDid: oldSessionDid, + ownerDid: "od", + ownerIdentityDid: "oid", + portalAddress: "0xPortal", + collabJoinEnabled: true, + roomInfo: "room-info-blob", + appType: "ddoc", + }); + } + return Promise.resolve(undefined); // newSessionDid not created yet + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const emitSpy = vi.fn(); + io.to = vi.fn(() => ({ emit: emitSpy })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.sessionManager.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + documentId, + sessionDid: newSessionDid, + ownerDid: "od", + ownerIdentityDid: "oid", + portalAddress: "0xPortal", + collabJoinEnabled: true, + roomInfo: "room-info-blob", + appType: "ddoc", + }) + ); + + expect(deps.mongodbStore.setMinEditEpoch).toHaveBeenCalledWith(documentId, 5); + + expect(io.to).toHaveBeenCalledWith(oldRoom); + expect(emitSpy).toHaveBeenCalledWith("/session/epoch_available", { + roomId: documentId, + epoch: 5, + payload: "opaque-ct", + }); + + expect(deps.rotationCoordinator.begin).toHaveBeenCalledWith( + expect.objectContaining({ + documentId, + oldSessionDid, + epoch: 5, + expected: ["sock-a", "sock-b"], + }) + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, newSessionDid, liveEditors: 2 }); + }); + + it("excludes a role-capped socket with no rail (e.g. the owner's headless joinOnly read) from expected/liveEditors", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", appType: "ddoc", + }); + } + return Promise.resolve(undefined); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const railessSock: any = { id: "sock-railess", data: { role: "editor" } }; // no rail — not an admitted editor + io.in = vi.fn(() => ({ + fetchSockets: vi.fn().mockResolvedValue([ownerSock, editorSockA, railessSock]), + })); + io.to = vi.fn(() => ({ emit: vi.fn() })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.rotationCoordinator.begin).toHaveBeenCalledWith( + expect.objectContaining({ expected: ["sock-a"] }) + ); + expect(r.json).toHaveBeenCalledWith({ ok: true, newSessionDid, liveEditors: 1 }); + }); + + it("does not recreate the new session row if it already exists (idempotent on retry)", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", + }); + } + return Promise.resolve({ documentId: docId, sessionDid: newSessionDid, ownerDid: "od" }); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("onCutover broadcasts /session/cutover then schedules terminateOldSession after the drain window", async () => { + vi.useFakeTimers(); + try { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", appType: "ddoc", + }); + } + return Promise.resolve(undefined); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const emitSpy = vi.fn(); + io.to = vi.fn(() => ({ emit: emitSpy })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + const onCutover = deps.rotationCoordinator.begin.mock.calls[0][0].onCutover; + expect(deps.terminateOldSession).not.toHaveBeenCalled(); + + onCutover(); + + expect(emitSpy).toHaveBeenCalledWith("/session/cutover", { roomId: documentId, epoch: 5 }); + expect(deps.terminateOldSession).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(deps.terminateOldSession).toHaveBeenCalledWith(documentId, oldSessionDid, "ddoc"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/tests/services/rotation-coordinator.test.ts b/src/tests/services/rotation-coordinator.test.ts new file mode 100644 index 0000000..7040d22 --- /dev/null +++ b/src/tests/services/rotation-coordinator.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { RotationCoordinator } from "../../services/rotation-coordinator"; + +describe("RotationCoordinator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires cutover once all expected sockets ack, before the timeout", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a", "b"], onCutover }); + c.recordAck("d", "a"); + expect(onCutover).not.toHaveBeenCalled(); + c.recordAck("d", "b"); + expect(onCutover).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(5000); + expect(onCutover).toHaveBeenCalledTimes(1); // not fired again by the timer + }); + + it("fires cutover on timeout even with a missing ack", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a", "b"], onCutover }); + c.recordAck("d", "a"); + vi.advanceTimersByTime(4000); + expect(onCutover).toHaveBeenCalledTimes(1); + }); + + it("with no expected sockets, cuts over immediately", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: [], onCutover }); + expect(onCutover).toHaveBeenCalledTimes(1); + }); + + it("ignores acks for an unknown or finished doc", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a"], onCutover }); + c.finish("d"); + c.recordAck("d", "a"); + expect(onCutover).not.toHaveBeenCalled(); + }); + + it("isActive is true only while an unfired barrier exists", () => { + const c = new RotationCoordinator(4000); + expect(c.isActive("d")).toBe(false); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a"], onCutover: vi.fn() }); + expect(c.isActive("d")).toBe(true); + c.recordAck("d", "a"); // fires + expect(c.isActive("d")).toBe(false); + }); + + it("a fired barrier does not leak: begin/fire works cleanly across two consecutive rotations for the same doc", () => { + const c = new RotationCoordinator(4000); + const onCutover1 = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s1", epoch: 1, expected: ["a"], onCutover: onCutover1 }); + c.recordAck("d", "a"); // fires via ack-complete + expect(onCutover1).toHaveBeenCalledTimes(1); + expect(c.isActive("d")).toBe(false); + + const onCutover2 = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s2", epoch: 2, expected: ["b"], onCutover: onCutover2 }); + expect(c.isActive("d")).toBe(true); + vi.advanceTimersByTime(4000); // fires via timeout + expect(onCutover2).toHaveBeenCalledTimes(1); + expect(onCutover1).toHaveBeenCalledTimes(1); // unaffected + expect(c.isActive("d")).toBe(false); + + // A late ack against the now-finished second barrier is a no-op. + c.recordAck("d", "b"); + expect(onCutover2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/services/socket-handlers.epochLoaded.test.ts b/src/tests/services/socket-handlers.epochLoaded.test.ts new file mode 100644 index 0000000..22a357c --- /dev/null +++ b/src/tests/services/socket-handlers.epochLoaded.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleEpochLoaded } from "../../services/socket-handlers"; +import { rotationCoordinator } from "../../services/rotation-coordinator"; +import { ErrorCode } from "../../types/index"; +import type { AppSocket, SocketData } from "../../types/index"; + +vi.mock("../../services/rotation-coordinator", () => ({ + rotationCoordinator: { recordAck: vi.fn() }, +})); + +const defaultSocketData: SocketData = { + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "editor", + authenticated: true, + appType: "ddoc", +}; + +function createFakeSocket(dataOverrides?: Partial): AppSocket { + const data: SocketData = { ...defaultSocketData, ...dataOverrides }; + return { + id: "socket-1", + data, + } as unknown as AppSocket; +} + +describe("handleEpochLoaded", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("records the ack and acks { ok: true } for an authenticated socket", () => { + const socket = createFakeSocket({ + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + }); + const callback = vi.fn(); + const args = { documentId: "test-document-id", epoch: 3 }; + + handleEpochLoaded(socket, args, callback); + + expect(rotationCoordinator.recordAck).toHaveBeenCalledWith("test-document-id", "socket-1"); + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { ok: true }, + }); + }); + + it("rejects an unauthenticated socket with NOT_AUTHENTICATED and never records an ack", () => { + const socket = createFakeSocket({ authenticated: false }); + const callback = vi.fn(); + const args = { documentId: "test-document-id", epoch: 3 }; + + handleEpochLoaded(socket, args, callback); + + expect(rotationCoordinator.recordAck).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + }); +}); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index d38d19e..c1c93f9 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -53,17 +53,22 @@ describe("handleAuth", () => { }; const fakeSessionManager = { getSession: vi.fn(), + getSessionIncludingTerminated: vi.fn(), getOtherNonTerminatedSessions: vi.fn(), terminateSession: vi.fn(), createSession: vi.fn(), updateRoomInfo: vi.fn(), addClientToSession: vi.fn(), + removeClientFromSession: vi.fn(), getCollabJoinEnabled: vi.fn(), getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), updateSessionOwnerDid: vi.fn(), }; - const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; + const fakeMongoDBStore = { + getDocumentMeta: vi.fn().mockResolvedValue(null), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + } as any; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, @@ -524,6 +529,71 @@ describe("handleAuth", () => { }); }); + it("rotationCutover: silently migrates room, skips user_joined, and cleans up the old session's client list", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + // Already-authed socket: socket.data.sessionDid holds the PRE-rotation sessionDid. + const fakeSocket = createFakeSocket(fakeBroadcastOperator, { + authenticated: true, + documentId: "doc-1", + sessionDid: "session-1-old", + role: "editor", + }); + (fakeSocket as any).leave = vi.fn(); + + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1-new", + collaborationToken: "collab-token", + rotationCutover: true, + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const oldRoomName = getRoomName(fakeArgs.documentId, "session-1-old"); + const newRoomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + + // Silent leave of the prior room — no /room/membership_change for it. + expect((fakeSocket as any).leave).toHaveBeenCalledWith(oldRoomName); + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledWith( + fakeArgs.documentId, + "session-1-old", + fakeSocket.id + ); + + // Normal admission into the new room is unaffected. + expect(fakeSocket.join).toHaveBeenCalledWith(newRoomName); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(fakeSocket.data.sessionDid).toBe(fakeArgs.sessionDid); + + // No membership blip on the rotation cutover join. + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalledWith( + "/room/membership_change", + expect.objectContaining({ action: "user_joined" }) + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + it("returns 404 when existing session is not found and no ownerToken is provided", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); @@ -1127,8 +1197,9 @@ describe("handleAuth", () => { expect(fakeSocket.data.role).toBe("owner"); }); - it("joinOnly: rejects ROOM_NOT_ESTABLISHED instead of creating a session", async () => { + it("joinOnly: rejects ROOM_NOT_ESTABLISHED for a session that never existed, even after the terminated-inclusive retry", async () => { fakeSessionManager.getSession.mockResolvedValue(null); + fakeSessionManager.getSessionIncludingTerminated.mockResolvedValue(undefined); const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); const callback = vi.fn(); @@ -1143,6 +1214,7 @@ describe("handleAuth", () => { joinOnly: true, }, callback); + expect(fakeSessionManager.getSessionIncludingTerminated).toHaveBeenCalledWith("doc-1", "session-1"); expect(callback).toHaveBeenCalledWith({ status: false, statusCode: 404, @@ -1152,6 +1224,98 @@ describe("handleAuth", () => { expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); }); + describe("owner-verified joinOnly read admission", () => { + const privateSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: "did:key:creator", + appType: "ddoc", + }; + const verifiedOwnerJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "identity-token", + identityContractAddress: "0x3333333333333333333333333333333333333333", + joinOnly: true, + }; + + it("1. admits a verified owner into an EXISTING private session with no gp/workspace/public grants", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...verifiedOwnerJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + // Bypass is admission-only: no gp/workspace/public grant was computed for this socket. + expect(fakeSocket.data.rail).toBeUndefined(); + expect(fakeSocket.data.railKind).toBeUndefined(); + }); + + it("2. admits a verified owner into a TERMINATED session via the terminated-inclusive lookup", async () => { + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeSessionManager.getSessionIncludingTerminated.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...verifiedOwnerJoinArgs }, callback); + + expect(fakeSessionManager.getSessionIncludingTerminated).toHaveBeenCalledWith("doc-1", "session-1"); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + }); + + it("3. still rejects a NON-owner (no grants) with JOIN_DISABLED — the gate still applies", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + // No ownerToken presented: ownerDid never resolves, so role resolves to "editor" + // pre-cap too — this bearer was never owner-shaped in the first place. + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + joinOnly: true, + }, + callback + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + }); + it("joinOnly: caps role at editor and skips the null-fill heal on an unbound session", async () => { fakeSessionManager.getSession.mockResolvedValue({ documentId: "doc-1", @@ -1187,7 +1351,11 @@ describe("handleAuth", () => { data: expect.objectContaining({ role: "editor" }), }) ); - expect(fakeSocket.data.rail).toBe("workspace"); + // This bearer resolved to "owner" pre-cap too (unbound session, shared-DID compare) — + // the same ambiguous match already gets full owner rights via the plain (non-joinOnly) + // path, so the rail gate is bypassed here rather than consulted. + expect(fakeSocket.data.rail).toBeUndefined(); + expect(fakeSessionManager.getWorkspaceEditEnabled).not.toHaveBeenCalled(); }); const boundSession = { @@ -1373,7 +1541,9 @@ describe("handleAuth", () => { "did:key:new" ); expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: true })); - expect(fakeSocket.data.rail).toBe("workspace"); + // Post-heal, ownerDid matches and no identityToken is presented, so this resolves + // to "owner" pre-cap — the rail gate is bypassed rather than consulted. + expect(fakeSocket.data.rail).toBeUndefined(); }); it("does NOT heal when the presented contractAddress differs from session.portalAddress", async () => { @@ -1492,7 +1662,10 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), }; - const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null) } as any; + const fakeMongoDBStore = { + getDocumentMeta: vi.fn().mockResolvedValue(null), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + } as any; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, @@ -1580,6 +1753,37 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () expect(socket.data.railKind).toBe("workspace"); }); + describe("epoch floor (minEditEpoch)", () => { + it("rejects a gp-actor editUcan whose epoch is below the doc's minEditEpoch floor", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 1 }); + fakeMongoDBStore.getMinEditEpoch.mockResolvedValue(2); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gp-token" }), cb); + expect(fakeMongoDBStore.getMinEditEpoch).toHaveBeenCalledWith("doc-1"); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + expect(deps.editBoundCache.check).not.toHaveBeenCalled(); + expect(socket.data.rail).toBeUndefined(); + }); + + it("admits a gp-actor editUcan whose epoch meets the doc's minEditEpoch floor", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 2 }); + fakeMongoDBStore.getMinEditEpoch.mockResolvedValue(2); + (deps.editBoundCache.check as ReturnType).mockResolvedValue("bound"); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gp-token" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("gp"); + expect(socket.data.railKind).toBe("gp-actor"); + expect(socket.data.actorHandle).toBe("h1"); + }); + }); + it("public bearer is admitted as rail=public ONLY on collabJoinEnabled === true", async () => { existingSessionSetup(); fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts index 925d0c5..a5deefc 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -3,6 +3,7 @@ import { handleDocumentUpdate, getRoomName } from "../../services/socket-handler import type { AppServer, AppSocket, DocumentUpdateArgs } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; import { ErrorCode } from "../../types"; +import { SessionTerminatedError } from "../../services/mongodb-store"; function createFakeIO(): AppServer { return {} as unknown as AppServer; @@ -289,6 +290,33 @@ describe("handleDocumentUpdate", () => { }); }); + it("acks 409 SESSION_TERMINATED and does not fan out when createUpdate rejects with SessionTerminatedError", 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 SessionTerminatedError()); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 409, + error: "Session terminated", + errorCode: ErrorCode.SESSION_TERMINATED, + }); + }); + 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 c484161..82d5560 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -120,6 +120,8 @@ export interface AuthArgs { /** Privilege-reducing join mode (workspace member): the server must never create or * bind a session for this connection, and the role is capped at editor. */ joinOnly?: boolean; + // in-place re-auth into the post-rotation sessionDid — suppress the membership blip and leave the old room silently. + rotationCutover?: boolean; } export interface AuthResponseData { @@ -283,6 +285,24 @@ export interface ServerErrorPayload { roomId: string; } +export interface EpochAvailablePayload { + roomId: string; + epoch: number; + /** opaque, double-wrapped: inner = blob/appLock wrap (excludes the removed actor), + * outer = roomKey_e (excludes the server). The server never unwraps it. */ + payload: string; +} + +export interface CutoverPayload { + roomId: string; + epoch: number; +} + +export interface EpochLoadedArgs { + documentId: string; + epoch: number; +} + // *************************************** // Socket.IO Typed Event Maps // *************************************** @@ -304,6 +324,7 @@ export interface ClientToServerEvents { "/documents/peers/list": ClientEventHandler; "/documents/awareness": ClientEventHandler; "/documents/terminate": ClientEventHandler; + "/session/epoch_loaded": ClientEventHandler; } export interface ServerToClientEvents { @@ -314,6 +335,8 @@ export interface ServerToClientEvents { "/document/meta_update": (data: MetaUpdatePayload) => void; "/room/membership_change": (data: MembershipChangePayload) => void; "/session/terminated": (data: SessionTerminatedPayload) => void; + "/session/epoch_available": (data: EpochAvailablePayload) => void; + "/session/cutover": (data: CutoverPayload) => void; } export interface InterServerEvents { From a165d3eb9693720bd1a81eded5a27efcebe06092 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Wed, 22 Jul 2026 18:12:28 +0530 Subject: [PATCH 09/11] feat(share): probe session existence via share-context + owner-op 404 diagnostics share-context GET accepts ?sessionDid= and answers sessionExists through the same getSession lookup the owner-ops open with, so a client can confirm the durable session exists before firing an op that would 404. The owner-op session-not-found branches now log the supplied sessionDid against the doc's non-terminated sessions (absence vs stale-key skew evidence). --- src/index.ts | 2 +- src/services/owner-op-routes.ts | 36 +++++++++++++++++++++- src/tests/services/owner-op-routes.test.ts | 20 ++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index d37ee9c..08d672e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,7 +138,7 @@ class CollaborationServer { ); this.app.get( "/documents/:documentId/share-context", - createShareContextHandler({ mongodbStore }) + createShareContextHandler({ mongodbStore, sessionManager }) ); this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index c433971..9b68c82 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -24,6 +24,25 @@ export interface OwnerOpDeps { >; } +// Absence-vs-skew evidence for the 404 class: "no sessions at all" means the owner +// socket never established the room; a non-empty list means the client derived a +// sessionDid the server doesn't have (stale/rotated roomKey). +async function logSessionNotFound( + op: string, + documentId: string, + sessionDid: string | undefined, + sessionManager: Pick +): Promise { + try { + const others = await sessionManager.getNonTerminatedSessionsForDocument(documentId); + console.warn( + `[owner-op:${op}] session not found doc=${documentId} did=${sessionDid} nonTerminated=[${others.map((t) => t.sessionDid).join(",")}]` + ); + } catch { + console.warn(`[owner-op:${op}] session not found doc=${documentId} did=${sessionDid}`); + } +} + export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; @@ -31,6 +50,7 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + await logSessionNotFound("collab-join-enabled", documentId, sessionDid, deps.sessionManager); res.status(404).json({ error: "Session not found" }); return; } @@ -84,6 +104,7 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + await logSessionNotFound("workspace-edit-tier", documentId, sessionDid, deps.sessionManager); res.status(404).json({ error: "Session not found" }); return; } @@ -139,6 +160,7 @@ export function createEvictEditActorsHandler( const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + await logSessionNotFound("evict-edit-actors", documentId, sessionDid, deps.sessionManager); res.status(404).json({ error: "Session not found" }); return; } @@ -225,6 +247,7 @@ export function createDeleteDocumentHandler(deps: { const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + console.warn(`[owner-op:delete] session not found doc=${documentId} did=${sessionDid}`); res.status(404).json({ error: "Session not found" }); return; } @@ -266,12 +289,23 @@ export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { +export function createShareContextHandler(deps: { + mongodbStore: Pick; + sessionManager?: Pick; +}) { return async (req: Request, res: Response): Promise => { // Express 4 doesn't catch async rejections — an unhandled store error // would hang the request (and the client has no timeout-free fallback). try { const ctx = await deps.mongodbStore.getShareContext(req.params.documentId); + // Optional probe: same lookup the owner-ops open with, so `sessionExists` + // answers exactly "will an owner-op on this sessionDid find the session". + const sessionDid = req.query?.sessionDid; + if (typeof sessionDid === "string" && sessionDid && deps.sessionManager) { + const session = await deps.sessionManager.getSession(req.params.documentId, sessionDid); + res.status(200).json({ ...ctx, sessionExists: !!session }); + return; + } res.status(200).json(ctx); } catch { res.status(500).json({ error: "share-context lookup failed" }); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 83e1bc2..cbfb00b 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -567,4 +567,24 @@ describe("share-context", () => { expect(r.status).toHaveBeenCalledWith(200); expect(r.json).toHaveBeenCalledWith({ exists: false, isPublished: false }); }); + + it("GET with ?sessionDid reports sessionExists via the same lookup owner-ops use", async () => { + const getSession = vi.fn().mockResolvedValueOnce({ sessionDid: "did-live" }).mockResolvedValueOnce(undefined); + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: true, isPublished: false }) }, + sessionManager: { getSession }, + }; + const r1 = res(); + await createShareContextHandler(shareDeps)( + { params: { documentId: "doc-1" }, query: { sessionDid: "did-live" } } as any, r1 + ); + expect(getSession).toHaveBeenCalledWith("doc-1", "did-live"); + expect(r1.json).toHaveBeenCalledWith({ exists: true, isPublished: false, sessionExists: true }); + + const r2 = res(); + await createShareContextHandler(shareDeps)( + { params: { documentId: "doc-1" }, query: { sessionDid: "did-stale" } } as any, r2 + ); + expect(r2.json).toHaveBeenCalledWith({ exists: true, isPublished: false, sessionExists: false }); + }); }); From cf6611fb024f3b2b8e8830380f5898c86b9f666b Mon Sep 17 00:00:00 2001 From: Nadeem Date: Wed, 22 Jul 2026 21:00:15 +0530 Subject: [PATCH 10/11] =?UTF-8?q?feat(auth):=20sunset=20legacyRoleFallback?= =?UTF-8?q?=20=E2=80=94=20token-less=20joins=20on=20bound=20sessions=20are?= =?UTF-8?q?=20never=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/index.ts | 5 --- src/services/socket-handlers.ts | 7 ++-- .../socket-handlers.handleAuth.test.ts | 34 ++++--------------- src/types/index.ts | 1 - 4 files changed, 10 insertions(+), 37 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 9b49213..82106af 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -24,11 +24,6 @@ export const config: ServerConfig = { auth: { serverDid: process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - // Sunset flag: while true, a token-less join on a bound - // session keeps the legacy ownerDid-compare role (old clients stay owners). Set - // LEGACY_ROLE_FALLBACK=false once identityToken-sending clients are fully rolled - // out — from then on a token-less join on a bound session is never owner. - legacyRoleFallback: process.env.LEGACY_ROLE_FALLBACK !== "false", }, rateLimit: { diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 61b3d27..7420a1c 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -375,8 +375,8 @@ export async function handleAuth( // 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, never a fallback; a token-less join keeps the legacy compare only while - // the sunset flag is on. + // 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) { const provenDid = args.identityContractAddress @@ -402,8 +402,7 @@ export async function handleAuth( } else if ( storedAppType === "ddoc" && boundIdentityDid && - !args.identityToken && - !config.auth.legacyRoleFallback + !args.identityToken ) { role = "editor"; } else { diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index c1c93f9..f137854 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleAuth, getRoomName } from "../../services/socket-handlers"; import { AppServer, AppSocket, AuthArgs, ErrorCode } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; -import { config } from "../../config"; function createFakeIO(options?: { broadcastOperator?: { emit: ReturnType }; @@ -1423,41 +1422,21 @@ describe("handleAuth", () => { }); }); - it("role: token-less join on a bound session → legacy owner while fallback is on", async () => { + it("role: token-less join on a bound session → editor (owner needs a proven identity token)", async () => { fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; const callback = vi.fn(); await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) ); }); - it("role: token-less join on a bound session → editor once the fallback is sunset", async () => { - const prior = config.auth.legacyRoleFallback; - config.auth.legacyRoleFallback = false; - try { - fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); - fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); - fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); - fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); - const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; - const callback = vi.fn(); - - await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); - - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) - ); - } finally { - config.auth.legacyRoleFallback = prior; - } - }); - it("workspace arm: rejects a FOREIGN portal's valid ownerToken (no cross-portal admission)", async () => { fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); @@ -1541,9 +1520,10 @@ describe("handleAuth", () => { "did:key:new" ); expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: true })); - // Post-heal, ownerDid matches and no identityToken is presented, so this resolves - // to "owner" pre-cap — the rail gate is bypassed rather than consulted. - expect(fakeSocket.data.rail).toBeUndefined(); + // Post-heal, ownerDid matches. A token-less join on a bound session is capped to + // "editor" (no legacy shared-DID owner), so the workspace member is admitted via the + // workspace rail (tier = edit) rather than the owner-shaped joinOnly bypass. + expect(fakeSocket.data.rail).toBe("workspace"); }); it("does NOT heal when the presented contractAddress differs from session.portalAddress", async () => { diff --git a/src/types/index.ts b/src/types/index.ts index 82d5560..e0e52cc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -403,7 +403,6 @@ export interface ServerConfig { auth: { serverDid: string; serverKeyPair?: any; - legacyRoleFallback: boolean; }; gate: { url: string | undefined; From ca3dd20a159e5c99ab3ada979fccc87b731011f5 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Thu, 23 Jul 2026 11:35:26 +0530 Subject: [PATCH 11/11] fix: verify owner identity from signed token; tombstone document deletion --- package.json | 2 + src/config/index.ts | 8 + src/cron/gracePurge.ts | 26 +++ src/cron/jobs/gracePurgeJob.ts | 25 +++ src/database/models/document-meta.ts | 6 + src/index.ts | 16 ++ src/services/auth.ts | 28 +-- src/services/deleted-file-webhook.ts | 36 ++++ src/services/mongodb-store.ts | 74 +++++++- src/services/owner-op-routes.ts | 35 ++-- src/services/rotate-route.ts | 4 +- src/services/session-manager.ts | 6 +- src/services/socket-handlers.ts | 65 ++++--- src/tests/cron/gracePurgeJob.test.ts | 55 ++++++ src/tests/services/auth.test.ts | 15 +- .../auth.verifyIdentityToken.fact.test.ts | 76 +++++++++ .../services/deleted-file-webhook.test.ts | 136 +++++++++++++++ .../services/mongodb-store.pinPortal.test.ts | 161 ++++++++++++++++++ src/tests/services/mongodb-store.test.ts | 7 +- .../services/mongodb-store.tombstone.test.ts | 109 ++++++++++++ src/tests/services/owner-op-routes.test.ts | 53 ++++-- src/tests/services/rotate-route.test.ts | 1 - src/tests/services/session-manager.test.ts | 13 +- .../socket-handlers.handleAuth.test.ts | 142 ++++++++++++--- src/types/index.ts | 10 +- 25 files changed, 1000 insertions(+), 109 deletions(-) create mode 100644 src/cron/gracePurge.ts create mode 100644 src/cron/jobs/gracePurgeJob.ts create mode 100644 src/services/deleted-file-webhook.ts create mode 100644 src/tests/cron/gracePurgeJob.test.ts create mode 100644 src/tests/services/auth.verifyIdentityToken.fact.test.ts create mode 100644 src/tests/services/deleted-file-webhook.test.ts create mode 100644 src/tests/services/mongodb-store.pinPortal.test.ts create mode 100644 src/tests/services/mongodb-store.tombstone.test.ts diff --git a/package.json b/package.json index e36351f..3460504 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "start": "node dist/index.js", "start:publish-reconcile": "node dist/cron/publishReconcile.js", "dev:publish-reconcile": "tsx watch src/cron/publishReconcile.ts", + "start:grace-purge": "node dist/cron/gracePurge.js", + "dev:grace-purge": "tsx watch src/cron/gracePurge.ts", "clean": "rm -rf dist", "format": "prettier --write .", "lint": "eslint src/**/*.ts", diff --git a/src/config/index.ts b/src/config/index.ts index 82106af..3c3914b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -37,6 +37,11 @@ export const config: ServerConfig = { interval: process.env.PUBLISH_RECONCILE_INTERVAL || "15 minutes", batchSize: parseInt(process.env.PUBLISH_RECONCILE_BATCH || "500"), }, + deleteGrace: { + windowMs: parseInt(process.env.DELETE_GRACE_WINDOW_MS || "2592000000"), // 30 days + interval: process.env.DELETE_GRACE_INTERVAL || "1 hour", + batchSize: parseInt(process.env.DELETE_GRACE_BATCH || "200"), + }, agenda: { concurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY || "1"), }, @@ -44,4 +49,7 @@ export const config: ServerConfig = { url: process.env.GATE_URL, did: process.env.GATE_DID, }, + webhook: { + apiKey: process.env.COLLAB_WEBHOOK_API_KEY, + }, }; \ No newline at end of file diff --git a/src/cron/gracePurge.ts b/src/cron/gracePurge.ts new file mode 100644 index 0000000..020cabe --- /dev/null +++ b/src/cron/gracePurge.ts @@ -0,0 +1,26 @@ +import { agenda } from "./agenda.js"; +import { databaseService } from "../database/index.js"; +import gracePurgeJob from "./jobs/gracePurgeJob.js"; + +async function graceful() { + await agenda.stop(); + await databaseService.disconnect(); + process.exit(0); +} + +(async () => { + try { + // The job queries mongoose models, so this worker needs its own DB connection + // (Agenda's Mongo connection only backs the jobs collection). + await databaseService.connect(); + await agenda.start(); + await gracePurgeJob.setupJob(); + console.log("grace-purge worker started"); + } catch (err) { + console.error(err); + await graceful(); + } +})(); + +process.on("SIGTERM", graceful); +process.on("SIGINT", graceful); diff --git a/src/cron/jobs/gracePurgeJob.ts b/src/cron/jobs/gracePurgeJob.ts new file mode 100644 index 0000000..cd7a845 --- /dev/null +++ b/src/cron/jobs/gracePurgeJob.ts @@ -0,0 +1,25 @@ +import type { Job } from "agenda"; +import { agenda } from "../agenda.js"; +import { config } from "../../config/index.js"; +import { mongodbStore } from "../../services/mongodb-store.js"; + +const jobName = "DELETE_GRACE_PURGE"; + +export async function jobDefinition(job: Job, done: (err?: Error) => void) { + try { + const cutoff = Date.now() - config.deleteGrace.windowMs; + const purged = await mongodbStore.purgeTombstonedBefore(cutoff, config.deleteGrace.batchSize); + console.log(`[${jobName}] purged=${purged.length}`); + done(); + } catch (err: any) { + console.error(`[${jobName}] ${err?.message ?? err}`); + done(err); + } +} + +async function setupJob() { + agenda.define(jobName, jobDefinition); + await agenda.every(config.deleteGrace.interval, jobName); +} + +export default { setupJob }; diff --git a/src/database/models/document-meta.ts b/src/database/models/document-meta.ts index a0e33f6..e5a8db2 100644 --- a/src/database/models/document-meta.ts +++ b/src/database/models/document-meta.ts @@ -10,6 +10,8 @@ interface IDocumentMeta extends MongooseDocument { title: string | null; // roomKey-encrypted updatedAt: number; isPublished: boolean; // set by the publish reconciler once the doc is on-chain + tombstonedAt: number | null; // set by the DeletedFile webhook; grace window runs from here + tombstoneReason: string | null; } const DocumentMetaSchema = new Schema({ @@ -22,6 +24,8 @@ const DocumentMetaSchema = new Schema({ title: { type: String, default: null }, updatedAt: { type: Number, required: true }, isPublished: { type: Boolean, default: false }, + tombstonedAt: { type: Number, default: null }, + tombstoneReason: { type: String, default: null }, }); // Discovery: "all docs bound to this identity / portal owner" for list-my-documents. @@ -29,6 +33,8 @@ const DocumentMetaSchema = new Schema({ // reconciler's candidate scan are index-served. DocumentMetaSchema.index({ ownerIdentityDid: 1, isPublished: 1 }, { background: true }); DocumentMetaSchema.index({ ownerDid: 1 }, { background: true }); +// Grace-window sweep: docs eligible for irreversible purge (see docs/architecture/edit-permission.md). +DocumentMetaSchema.index({ tombstonedAt: 1 }, { background: true }); export const DocumentMetaModel = mongoose.model("DocumentMeta", DocumentMetaSchema); export type { IDocumentMeta }; diff --git a/src/index.ts b/src/index.ts index 08d672e..530e150 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { createRotateSessionHandler } from "./services/rotate-route"; import { rotationCoordinator } from "./services/rotation-coordinator"; import { editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; +import { createDeletedFileWebhookHandler } from "./services/deleted-file-webhook"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; @@ -140,6 +141,21 @@ class CollaborationServer { "/documents/:documentId/share-context", createShareContextHandler({ mongodbStore, sessionManager }) ); + this.app.post( + "/webhooks/file-deleted", + createDeletedFileWebhookHandler({ + mongodbStore, + onTombstoned: async (documentId) => { + const sessions = await sessionManager.getNonTerminatedSessionsForDocument(documentId); + for (const s of sessions) { + 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"); + } + }, + }) + ); this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); this.app.delete( diff --git a/src/services/auth.ts b/src/services/auth.ts index 78af300..184370e 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -139,13 +139,18 @@ export class AuthService { } } - async verifyIdentityToken( - token: string, - identityContractAddress: Hex, - ddocId: string - ): Promise { + // `hierPart` is the UCAN capability scope the token must be signed for: a documentId for + // per-document owner-ops, or a portalAddress for the list-my-documents discovery route. + async verifyIdentityToken(token: string, hierPart: string): Promise { try { - const signingDid = await getIdentitySigningDid(identityContractAddress); + // Peek the signed fact (validate checks the token's own signature, not rootIssuer). + const parsed = await ucans.validate(token); + const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { identityContractAddress?: string }; + const addr = fact.identityContractAddress; + if (!addr || !addr.startsWith("0x")) return null; // fail closed: no signed address + + // The on-chain read is the anchor — do NOT trust the token's iss directly. + const signingDid = await getIdentitySigningDid(addr as Hex); if (!signingDid) return null; const result = await ucans.verify(token, { @@ -153,7 +158,7 @@ export class AuthService { requiredCapabilities: [ { capability: { - with: { scheme: "storage", hierPart: ddocId }, + with: { scheme: "storage", hierPart }, can: { namespace: "collaboration", segments: ["OWN"] }, }, rootIssuer: signingDid, @@ -172,18 +177,13 @@ export class AuthService { boundOwnerIdentityDid: string | null; boundOwnerDid: string | null; identityToken?: string; - identityContractAddress?: Hex; ownerToken?: string; ownerAddress?: Hex; portalAddress?: Hex; }): Promise { // Path 1 — bound identity (creator). The `=== boundOwnerIdentityDid` compare is MANDATORY. - if (params.identityToken && params.identityContractAddress && params.boundOwnerIdentityDid) { - const signingDid = await this.verifyIdentityToken( - params.identityToken, - params.identityContractAddress, - params.ddocId - ); + if (params.identityToken && params.boundOwnerIdentityDid) { + const signingDid = await this.verifyIdentityToken(params.identityToken, params.ddocId); if (signingDid && signingDid === params.boundOwnerIdentityDid) return true; } diff --git a/src/services/deleted-file-webhook.ts b/src/services/deleted-file-webhook.ts new file mode 100644 index 0000000..a19d649 --- /dev/null +++ b/src/services/deleted-file-webhook.ts @@ -0,0 +1,36 @@ +import type { Request, Response } from "express"; +import type { MongoDBStore } from "./mongodb-store"; +import { DocumentMetaModel } from "../database/models"; +import { config } from "../config"; + +export interface DeletedFileWebhookDeps { + mongodbStore: Pick; + onTombstoned?: (documentId: string) => Promise; // drop live sessions +} + +// Reversible tombstone from the on-chain DeletedFile event — see +// docs/architecture/edit-permission.md. Portal-matches the webhook payload against +// the immutable pin (DocumentMeta.portalAddress) so a colliding appFileId minted +// under an attacker-controlled portal cannot tombstone another owner's document. +export function createDeletedFileWebhookHandler(deps: DeletedFileWebhookDeps) { + return async (req: Request, res: Response): Promise => { + const secret = req.header("x-webhook-api-key"); + if (!config.webhook.apiKey || secret !== config.webhook.apiKey) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const { appFileId, portalAddress } = req.body || {}; + if (typeof appFileId !== "string" || typeof portalAddress !== "string") { + res.status(400).json({ error: "appFileId and portalAddress are required" }); + return; + } + const meta = await DocumentMetaModel.findById(appFileId).select("portalAddress").lean(); + if (!meta || (meta.portalAddress ?? "").toLowerCase() !== portalAddress.toLowerCase()) { + res.status(200).json({ ok: true, matched: false }); // idempotent no-op + return; + } + const done = await deps.mongodbStore.tombstoneDocument(appFileId, "onchain-delete"); + if (done && deps.onTombstoned) await deps.onTombstoned(appFileId); + res.status(200).json({ ok: true, matched: true }); + }; +} diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index e101cb8..4762d9c 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -251,11 +251,13 @@ export class MongoDBStore { await DocumentMetaModel.findOneAndUpdate( { _id: meta.documentId }, { - $set: { - sessionDid: meta.sessionDid, + $setOnInsert: { ownerDid: meta.ownerDid, ownerIdentityDid: meta.ownerIdentityDid, portalAddress: meta.portalAddress, + }, + $set: { + sessionDid: meta.sessionDid, editLock: meta.editLock, title: meta.title, updatedAt: Date.now(), @@ -265,6 +267,45 @@ export class MongoDBStore { ); } + // First-writer-immutable document->owner binding (see docs/architecture/edit-permission.md). + // The pin closes C1: a colliding appFileId minted on another portal can never rebind an + // already-pinned documentId. Returns the EFFECTIVE portalAddress so the caller can reject + // a mismatch atomically (no read-modify-write race). Two steps so a LEGACY row that predates + // the pin (portalAddress null/absent) gets backfilled instead of locking its real owner out — + // a filter-guarded upsert would dup-key on an already-pinned _id, so the ensure-then-backfill + // split is deliberate. Mirrors session-manager.fillOwnerIdentityDidIfAbsent. + async pinDocumentPortalIfAbsent(p: { + documentId: string; + portalAddress: string; + ownerDid: string | null; + ownerIdentityDid: string | null; + sessionDid: string; + }): Promise<{ portalAddress: string | null }> { + // 1. Ensure the row exists; pin all binding fields on INSERT only. + await DocumentMetaModel.findOneAndUpdate( + { _id: p.documentId }, + { + $setOnInsert: { + portalAddress: p.portalAddress, + ownerDid: p.ownerDid, + ownerIdentityDid: p.ownerIdentityDid, + sessionDid: p.sessionDid, + updatedAt: Date.now(), + isPublished: false, + }, + }, + { upsert: true, writeConcern: { w: "majority", j: true } } + ); + // 2. Backfill a legacy null/absent portal — never overwrites an existing real pin. + await DocumentMetaModel.updateOne( + { _id: p.documentId, $or: [{ portalAddress: null }, { portalAddress: { $exists: false } }] }, + { $set: { portalAddress: p.portalAddress } } + ); + // 3. Read the effective pin. + const doc: any = await DocumentMetaModel.findById(p.documentId).select("portalAddress").lean(); + return { portalAddress: doc?.portalAddress ?? null }; + } + async getDocumentMeta( documentId: string ): Promise<{ editLock: string | null; title: string | null } | null> { @@ -489,6 +530,35 @@ export class MongoDBStore { ]); } + // Reversible tombstone driven by the on-chain DeletedFile webhook (see + // docs/architecture/edit-permission.md). Idempotent: a second call against an + // already-tombstoned doc still returns true instead of erroring. + async tombstoneDocument(documentId: string, reason: string): Promise { + const res = await DocumentMetaModel.updateOne( + { _id: documentId, tombstonedAt: null }, + { $set: { tombstonedAt: Date.now(), tombstoneReason: reason } } + ); + if (res.matchedCount > 0) return true; + const existing: any = await DocumentMetaModel.findById(documentId).select("tombstonedAt").lean(); + return !!existing?.tombstonedAt; + } + + async isTombstoned(documentId: string): Promise { + const m: any = await DocumentMetaModel.findById(documentId).select("tombstonedAt").lean(); + return !!m?.tombstonedAt; + } + + // Grace-window irreversible purge. Reuses the existing collection deletes. + async purgeTombstonedBefore(cutoffMs: number, batchSize: number): Promise { + const rows: any[] = await DocumentMetaModel.find({ tombstonedAt: { $ne: null, $lte: cutoffMs } }) + .select("_id") + .limit(batchSize) + .lean(); + const ids = rows.map((r) => String(r._id)); + for (const id of ids) await this.purgeDocument(id); + 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. async collectOrphans(graceMs: number): Promise { diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 9b68c82..18348ed 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -46,7 +46,7 @@ async function logSessionNotFound( export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, enabled, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, enabled, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { @@ -59,7 +59,7 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) ddocId: documentId, boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, + identityToken, ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, }); if (!authorized) { @@ -100,7 +100,7 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, enabled, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, enabled, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { @@ -113,7 +113,7 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) ddocId: documentId, boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, + identityToken, ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, }); if (!authorized) { @@ -156,7 +156,7 @@ export function createEvictEditActorsHandler( ) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, handles, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, handles, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { @@ -169,7 +169,7 @@ export function createEvictEditActorsHandler( ddocId: documentId, boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, + identityToken, ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, }); if (!authorized) { @@ -215,11 +215,11 @@ export interface ListMyDocumentsDeps { export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) { return async (req: Request, res: Response): Promise => { - const { identityToken, identityContractAddress, portalAddress } = req.body || {}; + const { identityToken, portalAddress } = req.body || {}; // Path 1 — identity: the capability binds the portalAddress (a discovery scope, not a single ddocId). - if (identityToken && identityContractAddress && portalAddress) { - const signingDid = await deps.authService.verifyIdentityToken(identityToken, identityContractAddress, portalAddress); + if (identityToken && portalAddress) { + const signingDid = await deps.authService.verifyIdentityToken(identityToken, portalAddress); if (signingDid) { const documents = await deps.mongodbStore.listDocumentsForOwner({ ownerIdentityDid: signingDid }); res.status(200).json({ documents }); @@ -239,11 +239,11 @@ export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) { export function createDeleteDocumentHandler(deps: { authService: Pick; sessionManager: Pick; - mongodbStore: Pick; + mongodbStore: Pick; }) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { @@ -262,23 +262,30 @@ export function createDeleteDocumentHandler(deps: { ddocId: documentId, boundOwnerIdentityDid: session.ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress, + identityToken, ownerToken, ownerAddress, portalAddress, }); if (!authorized) { res.status(403).json({ error: "Not the document owner" }); return; } - await deps.mongodbStore.purgeDocument(documentId); + // Reversible tombstone, not a purge — see docs/architecture/edit-permission.md. + // Irreversible purge is driven only by the on-chain webhook + grace job. + await deps.mongodbStore.tombstoneDocument(documentId, "owner-delete"); res.status(200).json({ ok: true }); }; } -export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { +export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { return async (req: Request, res: Response): Promise => { // Open read: the payload is fileKey-ciphertext, useless without the key (public → linkKey; // private → gate/appLock). Access control is the key, not this endpoint. const documentId = req.params.documentId; + // Tombstoned docs serve 404 — see docs/architecture/edit-permission.md. + if (await deps.mongodbStore.isTombstoned(documentId)) { + res.status(404).json({ error: "Not found" }); + return; + } const mirror = await deps.mongodbStore.getLatestMirror(documentId); if (!mirror) { res.status(404).json({ error: "No mirror snapshot" }); diff --git a/src/services/rotate-route.ts b/src/services/rotate-route.ts index 2d05b38..5c73cb0 100644 --- a/src/services/rotate-route.ts +++ b/src/services/rotate-route.ts @@ -22,7 +22,7 @@ export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { const documentId = req.params.documentId; const { oldSessionDid, newSessionDid, payload, gateEpoch, - identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress, + identityToken, ownerToken, ownerAddress, portalAddress, } = req.body || {}; if ( @@ -53,7 +53,7 @@ export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { ddocId: documentId, boundOwnerIdentityDid: (oldSession as any).ownerIdentityDid ?? null, boundOwnerDid: oldSession.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, + identityToken, ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, }); if (!authorized) { diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index c399f7c..a755940 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -349,13 +349,13 @@ export class SessionManager { // rooms — a stale client-supplied sessionDid must not leave the current room open. async getNonTerminatedSessionsForDocument( documentId: string - ): Promise> { + ): Promise> { try { const sessions = await SessionModel.find( { documentId, state: { $ne: "terminated" } }, - { sessionDid: 1 } + { sessionDid: 1, appType: 1 } ).lean(); - return sessions.map((s: any) => ({ sessionDid: s.sessionDid })); + return sessions.map((s: any) => ({ sessionDid: s.sessionDid, appType: s.appType })); } 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 7420a1c..f5d1d0a 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -225,16 +225,11 @@ export async function handleAuth( // 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; + let boundOwnerIdentityDid: string | null = null; if (claimedAppType === "ddoc") { - const provenSigningDid = - args.identityToken && args.identityContractAddress - ? await authService.verifyIdentityToken( - args.identityToken, - args.identityContractAddress as Hex, - documentId - ) - : null; + const provenSigningDid = args.identityToken + ? await authService.verifyIdentityToken(args.identityToken, documentId) + : null; if (!provenSigningDid) { return callback({ status: false, @@ -247,6 +242,39 @@ export async function handleAuth( provenIdentityDid = provenSigningDid; } + // C1: pin documentId → portalAddress on the first owner bind, immutably (trust-on-first-use). + // This is bounded by the pre-first-durable-session race window, NOT by documentId secrecy — + // the portable share URL is /document/{id}#k=, so documentId is URL-path-visible (server/ + // link-visible); only the key is in the fragment. The ownerToken proves membership of the + // claimed portal; thereafter no other portal can bind. Residual: a share-link holder can + // pin-hijack a documentId that has never had a durable owner session, permanently locking + // out durable collaboration for it — but content (fileKey-encrypted mirror) and the + // on-chain/IPFS artifact are unaffected, and nothing can be purged. A clean fix + // (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, + }); + + 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 const otherSessions = await sessionManager.getOtherNonTerminatedSessions( documentId, @@ -289,7 +317,7 @@ export async function handleAuth( documentId, sessionDid, ownerDid, - ownerIdentityDid: boundOwnerIdentityDid, + ownerIdentityDid: boundOwnerIdentityDid ?? undefined, portalAddress: args.contractAddress, collabJoinEnabled: false, roomInfo: args.roomInfo, @@ -379,13 +407,7 @@ export async function handleAuth( // 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) { - const provenDid = args.identityContractAddress - ? await authService.verifyIdentityToken( - args.identityToken, - args.identityContractAddress as Hex, - documentId - ) - : null; + const provenDid = await authService.verifyIdentityToken(args.identityToken, documentId); if (!provenDid) { return callback({ status: false, @@ -426,14 +448,9 @@ export async function handleAuth( role === "owner" && args.joinOnly !== true && !existingSession.ownerIdentityDid && - args.identityToken && - args.identityContractAddress + args.identityToken ) { - const provenSigningDid = await authService.verifyIdentityToken( - args.identityToken, - args.identityContractAddress as Hex, - documentId - ); + const provenSigningDid = await authService.verifyIdentityToken(args.identityToken, documentId); if (provenSigningDid) { provenIdentityDid = provenSigningDid; await sessionManager.fillOwnerIdentityDidIfAbsent( diff --git a/src/tests/cron/gracePurgeJob.test.ts b/src/tests/cron/gracePurgeJob.test.ts new file mode 100644 index 0000000..4dcc832 --- /dev/null +++ b/src/tests/cron/gracePurgeJob.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const purgeTombstonedBefore = vi.fn(); + +vi.mock("../../cron/agenda", () => ({ + agenda: { define: vi.fn(), every: vi.fn() }, +})); + +vi.mock("../../config", () => ({ + config: { deleteGrace: { windowMs: 2592000000, interval: "1 hour", batchSize: 200 } }, +})); + +vi.mock("../../services/mongodb-store", () => ({ + mongodbStore: { purgeTombstonedBefore: (...args: unknown[]) => purgeTombstonedBefore(...args) }, +})); + +import { jobDefinition } from "../../cron/jobs/gracePurgeJob"; +import { config } from "../../config"; + +describe("gracePurgeJob jobDefinition", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("purges docs tombstoned before the grace-window cutoff and signals completion", async () => { + purgeTombstonedBefore.mockResolvedValue(["d1", "d2"]); + const done = vi.fn(); + + await jobDefinition({} as any, done); + + expect(purgeTombstonedBefore).toHaveBeenCalledTimes(1); + expect(purgeTombstonedBefore).toHaveBeenCalledWith( + Date.now() - config.deleteGrace.windowMs, + config.deleteGrace.batchSize + ); + expect(done).toHaveBeenCalledTimes(1); + expect(done.mock.calls[0][0]).toBeUndefined(); + }); + + it("forwards the store error to done on failure", async () => { + const err = new Error("mongo down"); + purgeTombstonedBefore.mockRejectedValue(err); + const done = vi.fn(); + + await jobDefinition({} as any, done); + + expect(done).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 8b84fe6..9875cb5 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -42,12 +42,14 @@ describe("verifyIdentityToken", () => { }); it("verifies the UCAN with rootIssuer = on-chain signingDid and a ddocId-bound capability", async () => { + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ identityContractAddress: "0xIdentity" }] } }); (getIdentitySigningDid as any).mockResolvedValue("did:key:zOwner"); (ucans.verify as any).mockResolvedValue({ ok: true }); const auth = new AuthService("did:server"); - const did = await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A"); + const did = await auth.verifyIdentityToken("tok", "ddoc-A"); + expect(getIdentitySigningDid).toHaveBeenCalledWith("0xIdentity"); expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ audience: "did:server", requiredCapabilities: [expect.objectContaining({ @@ -62,9 +64,10 @@ describe("verifyIdentityToken", () => { }); it("returns null when the on-chain DID cannot be resolved", async () => { + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ identityContractAddress: "0xIdentity" }] } }); (getIdentitySigningDid as any).mockResolvedValue(null); const auth = new AuthService("did:server"); - expect(await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A")).toBeNull(); + expect(await auth.verifyIdentityToken("tok", "ddoc-A")).toBeNull(); }); }); @@ -125,7 +128,7 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zOwner"); const ok = await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xIdentity" as any, + identityToken: "it", }); expect(ok).toBe(true); }); @@ -147,7 +150,7 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zAttacker"); // validly signed, wrong identity const ok = await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xAttacker" as any, + identityToken: "it", }); expect(ok).toBe(false); }); @@ -157,9 +160,9 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { const spy = vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue(null); await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xIdentity" as any, + identityToken: "it", }); - expect(spy).toHaveBeenCalledWith("it", "0xIdentity", "ddoc-A"); + expect(spy).toHaveBeenCalledWith("it", "ddoc-A"); }); }); diff --git a/src/tests/services/auth.verifyIdentityToken.fact.test.ts b/src/tests/services/auth.verifyIdentityToken.fact.test.ts new file mode 100644 index 0000000..a3b5375 --- /dev/null +++ b/src/tests/services/auth.verifyIdentityToken.fact.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as ucans from "@ucans/ucans"; +import { AuthService } from "../../services/auth"; +import { getIdentitySigningDid } from "../../utils/contract"; + +// Real round-trip: mint the same shape the client produces (facts, not an argument) and +// verify against a mocked chain read. See docs/architecture/edit-permission.md. +vi.mock("../../utils/contract", () => ({ getIdentitySigningDid: vi.fn() })); + +async function mint(issuer: ucans.EdKeypair, serverDid: string, ddocId: string, facts: Record[]) { + const u = await ucans.build({ + issuer, + audience: serverDid, + capabilities: [ + { + with: { scheme: "storage", hierPart: ddocId }, + can: { namespace: "collaboration", segments: ["OWN"] }, + }, + ], + facts, + lifetimeInSeconds: 3600, + }); + return ucans.encode(u); +} + +describe("verifyIdentityToken (UCAN fact round-trip)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("verifies and returns the on-chain signingDid when the fact + rootIssuer both check out", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + (getIdentitySigningDid as any).mockResolvedValue(issuer.did()); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBe(issuer.did()); + expect(getIdentitySigningDid).toHaveBeenCalledWith("0xabc0000000000000000000000000000000abc0"); + }); + + it("returns null when the token was minted without the identity fact", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", []); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + expect(getIdentitySigningDid).not.toHaveBeenCalled(); + }); + + it("returns null when the fact's address is unregistered on-chain (getIdentitySigningDid → null)", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + (getIdentitySigningDid as any).mockResolvedValue(null); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + }); + + it("returns null when the token's issuer does not root at the on-chain signingDid (wrong rootIssuer)", async () => { + const issuer = await ucans.EdKeypair.create(); + const attacker = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + // The chain says the address's signing key is the ATTACKER's DID, not the token's issuer. + (getIdentitySigningDid as any).mockResolvedValue(attacker.did()); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + }); +}); diff --git a/src/tests/services/deleted-file-webhook.test.ts b/src/tests/services/deleted-file-webhook.test.ts new file mode 100644 index 0000000..5088156 --- /dev/null +++ b/src/tests/services/deleted-file-webhook.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { findById: (...a: unknown[]) => findById(...a) }, + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +vi.mock("../../config", () => ({ + config: { webhook: { apiKey: "test-webhook-secret" } }, +})); + +import { createDeletedFileWebhookHandler } from "../../services/deleted-file-webhook"; +import { config } from "../../config"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +function req(headers: Record, body: unknown) { + return { + header: (name: string) => headers[name.toLowerCase()], + body, + } as any; +} + +describe("POST /webhooks/file-deleted", () => { + let mongodbStore: any; + let onTombstoned: ReturnType>>; + + beforeEach(() => { + vi.clearAllMocks(); + mongodbStore = { tombstoneDocument: vi.fn().mockResolvedValue(true) }; + onTombstoned = vi.fn<[string], Promise>().mockResolvedValue(undefined); + }); + + it("tombstones and drops sessions on valid secret + portal match", async () => { + findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xAbC" }) }), + }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(mongodbStore.tombstoneDocument).toHaveBeenCalledWith("doc-1", "onchain-delete"); + expect(onTombstoned).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: true }); + }); + + it("401s on wrong secret and never tombstones", async () => { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "wrong-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(r.status).toHaveBeenCalledWith(401); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + }); + + it("no-ops on portal mismatch (colliding-appFileId defense) and never tombstones", async () => { + findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xOwnerPortal" }) }), + }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xAttackerPortal" }), + r + ); + + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: false }); + }); + + it("no-ops on unknown appFileId and never tombstones", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "missing", portalAddress: "0xabc" }), + r + ); + + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: false }); + }); + + it("401s when config.webhook.apiKey is unset, even with a header, and never tombstones", async () => { + const original = config.webhook.apiKey; + config.webhook.apiKey = undefined; + try { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(r.status).toHaveBeenCalledWith(401); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + } finally { + config.webhook.apiKey = original; + } + }); + + it("400s when appFileId or portalAddress is missing", async () => { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler(req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1" }), r); + + expect(r.status).toHaveBeenCalledWith(400); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/mongodb-store.pinPortal.test.ts b/src/tests/services/mongodb-store.pinPortal.test.ts new file mode 100644 index 0000000..9614ee5 --- /dev/null +++ b/src/tests/services/mongodb-store.pinPortal.test.ts @@ -0,0 +1,161 @@ +// HONESTY NOTE: real first-writer-wins immutability is enforced by Mongo `_id` +// uniqueness + `$setOnInsert`, which this mock cannot exercise. These tests verify +// the store ISSUES the correct atomic operators — the accepted test level for this +// repo's mocked-Mongoose store suite (see mongodb-store.minEditEpoch.test.ts). +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findOneAndUpdate = vi.fn(); +const updateOne = vi.fn(); +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { + findOneAndUpdate: (...a: unknown[]) => findOneAndUpdate(...a), + updateOne: (...a: unknown[]) => updateOne(...a), + findById: (...a: unknown[]) => findById(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("pinDocumentPortalIfAbsent", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + findOneAndUpdate.mockResolvedValue({}); + updateOne.mockResolvedValue({}); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + }); + + it("pins the binding fields via $setOnInsert, never $set", async () => { + await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "d1" }, + { + $setOnInsert: expect.objectContaining({ + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }), + }, + { upsert: true, writeConcern: { w: "majority", j: true } } + ); + const [, updatePayload] = findOneAndUpdate.mock.calls[0]; + expect(updatePayload.$set).toBeUndefined(); + }); + + it("issues the backfill updateOne with the null-guard filter", async () => { + await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }); + + expect(updateOne).toHaveBeenCalledWith( + { _id: "d1", $or: [{ portalAddress: null }, { portalAddress: { $exists: false } }] }, + { $set: { portalAddress: "0xP1" } } + ); + }); + + it("returns the effective (post-insert) portalAddress", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xATTACKER", + ownerDid: "did:key:o2", + ownerIdentityDid: "did:key:i2", + sessionDid: "did:key:s2", + }); + + // The caller's own portal is irrelevant to the return value — the effective + // pin comes from the post-backfill read, which the mock stubs as "0xP1". + expect(result).toEqual({ portalAddress: "0xP1" }); + }); + + it("legacy null-portal row: backfill runs, caller's portal wins the return (not null)", async () => { + // Simulates a pre-existing row with portalAddress: null — the backfill updateOne + // (asserted above) is what actually sets it; here we assert the read-back reflects + // the caller's portal rather than the mock returning a lockout `null`. + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "legacy-doc", + portalAddress: "0xP1", + ownerDid: null, + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s", + }); + + expect(result.portalAddress).toBe("0xP1"); + expect(result.portalAddress).not.toBeNull(); + }); + + it("returns null when no row is found at all", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "missing", + portalAddress: "0xP1", + ownerDid: null, + ownerIdentityDid: null, + sessionDid: "did:key:s", + }); + + expect(result).toEqual({ portalAddress: null }); + }); +}); + +describe("upsertDocumentMeta binding immutability", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + findOneAndUpdate.mockResolvedValue({}); + }); + + it("issues $setOnInsert for binding fields and $set for mutable fields", async () => { + await store.upsertDocumentMeta({ + documentId: "d2", + sessionDid: "did:key:s", + ownerDid: "did:key:x", + ownerIdentityDid: "did:key:x", + portalAddress: "0xEVIL", + editLock: "lk", + title: "t", + }); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "d2" }, + { + $setOnInsert: { + ownerDid: "did:key:x", + ownerIdentityDid: "did:key:x", + portalAddress: "0xEVIL", + }, + $set: expect.objectContaining({ + sessionDid: "did:key:s", + editLock: "lk", + title: "t", + }), + }, + { upsert: true, new: true, writeConcern: { w: "majority", j: true } } + ); + const [, updatePayload] = findOneAndUpdate.mock.calls[0]; + expect(updatePayload.$set.ownerDid).toBeUndefined(); + expect(updatePayload.$set.ownerIdentityDid).toBeUndefined(); + expect(updatePayload.$set.portalAddress).toBeUndefined(); + }); +}); diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 39b38dd..6dd25f5 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -213,9 +213,12 @@ describe("upsertDocumentMeta", () => { expect(DocumentMetaModel.findOneAndUpdate).toHaveBeenCalledWith( { _id: "doc-1" }, expect.objectContaining({ + // Binding fields are first-writer-immutable (C1) — pinned on insert only. + $setOnInsert: expect.objectContaining({ + ownerDid: "od", ownerIdentityDid: "oid", portalAddress: "0xP", + }), $set: expect.objectContaining({ - sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", - portalAddress: "0xP", editLock: "el", title: "t", + sessionDid: "room-did", editLock: "el", title: "t", }), }), expect.objectContaining({ upsert: true, writeConcern: { w: "majority", j: true } }) diff --git a/src/tests/services/mongodb-store.tombstone.test.ts b/src/tests/services/mongodb-store.tombstone.test.ts new file mode 100644 index 0000000..e9fd9e4 --- /dev/null +++ b/src/tests/services/mongodb-store.tombstone.test.ts @@ -0,0 +1,109 @@ +// HONESTY NOTE: real once-only tombstone semantics come from the Mongo filter +// `{ tombstonedAt: null }` on `updateOne`, which this mock cannot exercise. These +// tests verify the store ISSUES the correct queries/operators — the accepted test +// level for this repo's mocked-Mongoose store suite (see mongodb-store.minEditEpoch.test.ts). +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const updateOne = vi.fn(); +const findById = vi.fn(); +const find = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { + updateOne: (...a: unknown[]) => updateOne(...a), + findById: (...a: unknown[]) => findById(...a), + find: (...a: unknown[]) => find(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("tombstoneDocument", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sets tombstonedAt/tombstoneReason on a live doc and returns true", async () => { + updateOne.mockResolvedValue({ matchedCount: 1 }); + + const result = await store.tombstoneDocument("d1", "on-chain-delete"); + + expect(updateOne).toHaveBeenCalledWith( + { _id: "d1", tombstonedAt: null }, + { $set: { tombstonedAt: expect.any(Number), tombstoneReason: "on-chain-delete" } } + ); + expect(result).toBe(true); + }); + + it("is idempotent: already-tombstoned doc still returns true", async () => { + updateOne.mockResolvedValue({ matchedCount: 0 }); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ tombstonedAt: 123 }) }) }); + + const result = await store.tombstoneDocument("d1", "on-chain-delete"); + + expect(result).toBe(true); + }); + + it("returns false when no row matches and the doc doesn't exist", async () => { + updateOne.mockResolvedValue({ matchedCount: 0 }); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + + const result = await store.tombstoneDocument("missing", "on-chain-delete"); + + expect(result).toBe(false); + }); +}); + +describe("isTombstoned", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when tombstonedAt is set", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ tombstonedAt: 123 }) }) }); + expect(await store.isTombstoned("d1")).toBe(true); + }); + + it("returns false when tombstonedAt is null/absent", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + expect(await store.isTombstoned("d1")).toBe(false); + }); +}); + +describe("purgeTombstonedBefore", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("purges only rows past the cutoff via purgeDocument, and returns their ids", async () => { + find.mockReturnValue({ + select: () => ({ limit: () => ({ lean: () => Promise.resolve([{ _id: "d1" }, { _id: "d2" }]) }) }), + }); + const purgeDocument = vi.spyOn(store, "purgeDocument").mockResolvedValue(undefined); + + const cutoffMs = 1000; + const result = await store.purgeTombstonedBefore(cutoffMs, 50); + + expect(find).toHaveBeenCalledWith({ tombstonedAt: { $ne: null, $lte: cutoffMs } }); + expect(purgeDocument).toHaveBeenCalledWith("d1"); + expect(purgeDocument).toHaveBeenCalledWith("d2"); + expect(purgeDocument).toHaveBeenCalledTimes(2); + expect(result).toEqual(["d1", "d2"]); + }); + + it("returns an empty array when nothing is past the cutoff", async () => { + find.mockReturnValue({ select: () => ({ limit: () => ({ lean: () => Promise.resolve([]) }) }) }); + const purgeDocument = vi.spyOn(store, "purgeDocument").mockResolvedValue(undefined); + + const result = await store.purgeTombstonedBefore(1000, 50); + + expect(purgeDocument).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index cbfb00b..86e629d 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -199,7 +199,7 @@ describe("POST /list-my-documents", () => { deps.authService.verifyIdentityToken.mockResolvedValue("did:key:zOwner"); const r = res(); await createListMyDocumentsHandler(deps)( - { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + { body: { identityToken: "it", portalAddress: "0xP" } } as any, r ); expect(deps.mongodbStore.listDocumentsForOwner).toHaveBeenCalledWith({ ownerIdentityDid: "did:key:zOwner" }); expect(r.status).toHaveBeenCalledWith(200); @@ -217,7 +217,7 @@ describe("POST /list-my-documents", () => { deps.authService.verifyIdentityToken.mockResolvedValue(null); const r = res(); await createListMyDocumentsHandler(deps)( - { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + { body: { identityToken: "it", portalAddress: "0xP" } } as any, r ); expect(r.status).toHaveBeenCalledWith(401); expect(deps.mongodbStore.listDocumentsForOwner).not.toHaveBeenCalled(); @@ -230,7 +230,7 @@ describe("DELETE /documents/:id", () => { deps = { authService: { verifyOwnerOp: vi.fn() }, sessionManager: { getSession: vi.fn() }, - mongodbStore: { purgeDocument: vi.fn() }, + mongodbStore: { tombstoneDocument: vi.fn(), purgeDocument: vi.fn() }, }; }); @@ -239,15 +239,16 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(403); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); }); - it("purges everything for the two-proof owner", async () => { + it("tombstones (never purges) for the two-proof owner", async () => { deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); deps.authService.verifyOwnerOp.mockResolvedValue(true); const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); - expect(deps.mongodbStore.purgeDocument).toHaveBeenCalledWith("d"); + expect(deps.mongodbStore.tombstoneDocument).toHaveBeenCalledWith("d", "owner-delete"); + expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); expect(r.status).toHaveBeenCalledWith(200); }); @@ -257,7 +258,7 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(403); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); }); it("404s when there is no session for the document", async () => { @@ -265,13 +266,18 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(404); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); }); }); describe("GET /documents/:id/mirror", () => { it("returns the latest mirror snapshot", async () => { - const deps: any = { mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }) } }; + const deps: any = { + mongodbStore: { + getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }), + isTombstoned: vi.fn().mockResolvedValue(false), + }, + }; const r = res(); await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); expect(deps.mongodbStore.getLatestMirror).toHaveBeenCalledWith("doc-1"); @@ -280,11 +286,38 @@ describe("GET /documents/:id/mirror", () => { }); it("404s when there is no mirror yet", async () => { - const deps: any = { mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue(null) } }; + const deps: any = { + mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue(null), isTombstoned: vi.fn().mockResolvedValue(false) }, + }; const r = res(); await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); expect(r.status).toHaveBeenCalledWith(404); }); + + it("404s a tombstoned doc without touching the mirror fetch", async () => { + const deps: any = { + mongodbStore: { getLatestMirror: vi.fn(), isTombstoned: vi.fn().mockResolvedValue(true) }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.isTombstoned).toHaveBeenCalledWith("doc-1"); + expect(deps.mongodbStore.getLatestMirror).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(404); + expect(r.json).toHaveBeenCalledWith({ error: "Not found" }); + }); + + it("proceeds to the normal read when the doc is not tombstoned", async () => { + const deps: any = { + mongodbStore: { + getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 1, createdAt: 1 }), + isTombstoned: vi.fn().mockResolvedValue(false), + }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.getLatestMirror).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + }); }); describe("createEvictWorkspaceMemberHandler", () => { diff --git a/src/tests/services/rotate-route.test.ts b/src/tests/services/rotate-route.test.ts index bbac232..bf7c851 100644 --- a/src/tests/services/rotate-route.test.ts +++ b/src/tests/services/rotate-route.test.ts @@ -52,7 +52,6 @@ describe("POST /documents/:id/rotate-session", () => { payload: "opaque-ct", gateEpoch: 5, identityToken: "it", - identityContractAddress: "0xI", ownerToken: "ot", ownerAddress: "0xO", portalAddress: "0xP", diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index 5770ad6..1dce662 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -219,11 +219,11 @@ describe("SessionManager", () => { // getNonTerminatedSessionsForDocument - it("returns every non-terminated sessionDid for a document regardless of owner", async () => { + it("returns every non-terminated sessionDid (with appType) for a document regardless of owner", async () => { vi.mocked(SessionModel.find).mockReturnValue({ lean: vi.fn().mockResolvedValue([ - { sessionDid: "s-1" }, - { sessionDid: "s-2" }, + { sessionDid: "s-1", appType: "ddoc" }, + { sessionDid: "s-2", appType: "dsheet" }, ]), } as any); @@ -231,9 +231,12 @@ describe("SessionManager", () => { expect(SessionModel.find).toHaveBeenCalledWith( { documentId: "doc-1", state: { $ne: "terminated" } }, - { sessionDid: 1 } + { sessionDid: 1, appType: 1 } ); - expect(sessions).toEqual([{ sessionDid: "s-1" }, { sessionDid: "s-2" }]); + expect(sessions).toEqual([ + { sessionDid: "s-1", appType: "ddoc" }, + { sessionDid: "s-2", appType: "dsheet" }, + ]); }); // getActiveSessionsCount diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index f137854..5873552 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -67,6 +67,13 @@ describe("handleAuth", () => { const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null), getMinEditEpoch: vi.fn().mockResolvedValue(0), + // Echoes the requested portal back, so unrelated owner-bind tests pass regardless + // of their contractAddress. Mismatch tests override with mockResolvedValueOnce. + pinDocumentPortalIfAbsent: vi + .fn() + .mockImplementation(async (p: { portalAddress: string }) => ({ + portalAddress: p.portalAddress, + })), } as any; const deps: SocketHandlerDeps = { @@ -152,7 +159,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -226,6 +232,112 @@ describe("handleAuth", () => { }); }); + describe("owner session portal pin (C1)", () => { + const pinArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + roomInfo: "room-info", + }; + + function ownerBindHappyPath() { + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeSessionManager.createSession.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + } + + it("(a) pins the portal on the first owner bind of an unpinned doc and creates the session", async () => { + ownerBindHappyPath(); + // Default mock echoes the requested portal back (first-writer-wins with no prior pin). + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeMongoDBStore.pinDocumentPortalIfAbsent).toHaveBeenCalledWith({ + documentId: pinArgs.documentId, + portalAddress: pinArgs.contractAddress, + ownerDid: "owner-did", + ownerIdentityDid: "owner-identity-did", + sessionDid: pinArgs.sessionDid, + }); + expect(fakeSessionManager.createSession).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("(b) succeeds when the bind's portal equals the existing pin", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: pinArgs.contractAddress, + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeSessionManager.createSession).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("(c) rejects 403/AUTH_TOKEN_INVALID when the bind's portal differs from the pin, without creating a session", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: "0x9999999999999999999999999999999999999999", + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, 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("(d) the mismatch reject precedes the terminate-other-sessions loop", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: "0x9999999999999999999999999999999999999999", + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeSessionManager.getOtherNonTerminatedSessions).not.toHaveBeenCalled(); + expect(fakeSessionManager.terminateSession).not.toHaveBeenCalled(); + }); + + it("(e) rejects 403/AUTH_TOKEN_INVALID when the pin read fails (portalAddress: null), without creating a session", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: null, + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, 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("returns the latest stored DocumentMeta title in the ack", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); @@ -237,7 +349,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -270,7 +381,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -306,7 +416,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -1097,7 +1206,7 @@ describe("handleAuth", () => { ownerToken: "owner-token", ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", - // no identityToken / identityContractAddress + // no identityToken }; const callback = vi.fn(); @@ -1127,7 +1236,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "bad-identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", }; const callback = vi.fn(); @@ -1137,11 +1245,7 @@ describe("handleAuth", () => { await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); - expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( - "bad-identity-token", - "0x0000000000000000000000000000000000000003", - "doc-1" - ); + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith("bad-identity-token", "doc-1"); expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith({ status: false, @@ -1162,7 +1266,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", }; const callback = vi.fn(); @@ -1183,11 +1286,7 @@ describe("handleAuth", () => { await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); - expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( - "identity-token", - "0x0000000000000000000000000000000000000003", - "doc-1" - ); + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith("identity-token", "doc-1"); expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).toHaveBeenCalledWith( "doc-1", "session-1", @@ -1239,7 +1338,6 @@ describe("handleAuth", () => { ownerAddress: "0x1111111111111111111111111111111111111111", contractAddress: "0x2222222222222222222222222222222222222222", identityToken: "identity-token", - identityContractAddress: "0x3333333333333333333333333333333333333333", joinOnly: true, }; @@ -1339,7 +1437,6 @@ describe("handleAuth", () => { ownerAddress: "0x1111111111111111111111111111111111111111", contractAddress: "0x2222222222222222222222222222222222222222", identityToken: "member-identity-token", - identityContractAddress: "0x3333333333333333333333333333333333333333", joinOnly: true, }, callback); @@ -1372,7 +1469,6 @@ describe("handleAuth", () => { ownerAddress: "0x1111111111111111111111111111111111111111", contractAddress: "0x2222222222222222222222222222222222222222", identityToken: "identity-token", - identityContractAddress: "0x3333333333333333333333333333333333333333", }; it("role: proven identity == bound + matching ownerToken → owner", async () => { @@ -1427,7 +1523,7 @@ describe("handleAuth", () => { fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); - const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; + const { identityToken, ...tokenless } = boundJoinArgs; const callback = vi.fn(); await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); @@ -1577,7 +1673,6 @@ describe("handleAuth", () => { ...rotatedJoinArgs, joinOnly: false, identityToken: "identity-token", - identityContractAddress: "0x3333333333333333333333333333333333333333", }, callback ); @@ -1612,7 +1707,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); - const { identityToken, identityContractAddress, ...tokenless } = boundJoinArgs; + const { identityToken, ...tokenless } = boundJoinArgs; const fakeSocket = createFakeSocket(); const callback = vi.fn(); @@ -1724,7 +1819,6 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () ownerAddress: "0x1111111111111111111111111111111111111111", contractAddress: "0x2222222222222222222222222222222222222222", identityToken: "it", - identityContractAddress: "0x3333333333333333333333333333333333333333", }), cb ); diff --git a/src/types/index.ts b/src/types/index.ts index e0e52cc..e9600ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -112,9 +112,7 @@ export interface AuthArgs { contractAddress?: string; roomInfo?: string; appType?: AppType; - ownerIdentityDid?: string; identityToken?: string; - identityContractAddress?: string; editUcan?: string; actorHandle?: string; /** Privilege-reducing join mode (workspace member): the server must never create or @@ -419,7 +417,15 @@ export interface ServerConfig { interval: string; batchSize: number; }; + deleteGrace: { + windowMs: number; + interval: string; + batchSize: number; + }; agenda: { concurrency: number; }; + webhook: { + apiKey?: string; + }; } \ No newline at end of file