diff --git a/src/config/index.ts b/src/config/index.ts index 3c3914b..7c59fdd 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -46,7 +46,6 @@ export const config: ServerConfig = { concurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY || "1"), }, gate: { - url: process.env.GATE_URL, did: process.env.GATE_DID, }, webhook: { diff --git a/src/index.ts b/src/index.ts index 530e150..4856c52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,6 @@ import { } 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 { createDeletedFileWebhookHandler } from "./services/deleted-file-webhook"; import { createLightNode } from "@waku/sdk"; @@ -110,7 +109,7 @@ class CollaborationServer { ); this.app.post( "/documents/:documentId/evict-edit-actors", - createEvictEditActorsHandler({ authService, sessionManager, editBoundCache, mongodbStore }, this.io) + createEvictEditActorsHandler({ authService, sessionManager, mongodbStore }, this.io) ); this.app.post( "/workspaces/:portalAddress/evict-member", @@ -189,12 +188,6 @@ class CollaborationServer { 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 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 edit revocation would never take effect. Set GATE_URL to the gate origin, or unset GATE_DID to disable GP editing." - ); } // Initialize database connection diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index afc5b58..7d7ca38 100644 --- a/src/services/flush-route.ts +++ b/src/services/flush-route.ts @@ -2,17 +2,18 @@ import type { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import type { AuthService } from "./auth"; import { SessionTerminatedError, type MongoDBStore } from "./mongodb-store"; +import { resolveEditAdmission } from "./socket-handlers"; export const FLUSH_MAX_BYTES = 2 * 1024 * 1024; // a final delta is small; cap well under the 10MB body limit export interface FlushDeps { - authService: Pick; - mongodbStore: Pick; + authService: Pick; + mongodbStore: Pick; } export function createFlushHandler(deps: FlushDeps) { return async (req: Request, res: Response): Promise => { - const { documentId, sessionDid, collaborationToken, data } = req.body || {}; + const { documentId, sessionDid, collaborationToken, data, editUcan } = req.body || {}; if (!documentId || !sessionDid || !collaborationToken || !data) { res.status(400).json({ error: "documentId, sessionDid, collaborationToken and data are required" }); return; @@ -28,6 +29,18 @@ export function createFlushHandler(deps: FlushDeps) { return; } + // When the beacon carries a gp-actor editUcan (the same value sent at JOIN), re-run JOIN's + // offline admission here so a below-floor or revoked claim is refused on the durable-write + // path too. Rails without a claim (public/workspace/owner) skip it, mirroring JOIN's + // `if (args.editUcan)` branch. + if (typeof editUcan === "string" && editUcan) { + const admission = await resolveEditAdmission(deps, editUcan, documentId); + if (!admission.ok) { + res.status(403).json({ error: "Edit access is not authorized for this document" }); + return; + } + } + // 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`. diff --git a/src/services/gate-epoch.ts b/src/services/gate-epoch.ts deleted file mode 100644 index 00b74d7..0000000 --- a/src/services/gate-epoch.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { config } from "../config"; - -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). - * Expiry-on-read + single-flight, keyed per (docId, handle); fail-closed on a - * cold/never-fetched entry. - */ -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 18348ed..9338b55 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -3,7 +3,6 @@ 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 { EditBoundCache } from "./gate-epoch"; import { getRoomName } from "./socket-handlers"; import { Hex, isAddress } from "viem"; import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../utils/contract"; @@ -147,11 +146,11 @@ export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) }; } -/** 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. */ +/** Targeted eviction of specific gp-actor handles: stamp the epoch floor, then drop the + * matching live sockets across every session room of the doc. Co-editors with other handles + * are untouched. The offline minEditEpoch floor (plus rotation) supersedes the removed cache. */ export function createEvictEditActorsHandler( - deps: OwnerOpDeps & { editBoundCache: EditBoundCache; mongodbStore: Pick }, + deps: OwnerOpDeps & { mongodbStore: Pick }, io: AppServer ) { return async (req: Request, res: Response): Promise => { @@ -185,7 +184,6 @@ export function createEvictEditActorsHandler( } const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; - deps.editBoundCache.evict(documentId, list); let dropped = 0; if (list.length > 0) { diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts index 31047b1..5a09831 100644 --- a/src/services/socket-handlers.deps.ts +++ b/src/services/socket-handlers.deps.ts @@ -1,11 +1,9 @@ import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; -import type { EditBoundCache } from "./gate-epoch"; export interface SocketHandlerDeps { authService: AuthService; sessionManager: SessionManager; mongodbStore: MongoDBStore; - editBoundCache: EditBoundCache; } diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index a14473e..927e4c2 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -27,7 +27,6 @@ import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; 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"; @@ -37,7 +36,6 @@ const defaultDeps: SocketHandlerDeps = { authService, sessionManager, mongodbStore, - editBoundCache, }; function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { @@ -163,6 +161,7 @@ export async function handleAuth( let rail: "gp" | "workspace" | "public" | undefined = undefined; let railKind: "gp-actor" | "workspace" | "public" | undefined = undefined; let actorHandle: string | undefined = undefined; + let admittedEditEpoch: number | undefined = undefined; let provenIdentityDid: string | null = null; // joinOnly is strictly privilege-reducing: never create or bind a room on behalf of @@ -474,32 +473,15 @@ export async function handleAuth( // 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); - 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, - }); - } + // Offline gp-actor admission: valid editUcan + epoch at/above the minEditEpoch floor. + // No gate poll — rotation bumps the floor and terminates the old session, confining a + // removed actor. + const admission = await resolveEditAdmission(deps, args.editUcan, documentId); + if (admission.ok) { + rail = "gp"; + railKind = "gp-actor"; + actorHandle = admission.editHandle; + admittedEditEpoch = admission.epoch; } else { return callback({ status: false, @@ -573,6 +555,7 @@ export async function handleAuth( socket.data.railKind = railKind; socket.data.actorHandle = actorHandle; socket.data.actorIdentityDid = provenIdentityDid ?? undefined; + socket.data.editEpoch = admittedEditEpoch; // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); @@ -631,15 +614,37 @@ 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. +// The slice of deps offline edit-admission needs. Narrower than SocketHandlerDeps so the /flush +// route can reuse resolveEditAdmission without carrying the full socket-handler dependency set. +export type EditAdmissionDeps = { + authService: Pick; + mongodbStore: Pick; +}; + +// Offline per-actor edit admission: a valid gate-signed editUcan whose epoch is at/above the +// doc's minEditEpoch floor. No gate network call — rotation bumps the floor and terminates the +// old session, so a removed actor's stale editUcan is rejected offline. +export async function resolveEditAdmission( + deps: EditAdmissionDeps, + editUcan: string, + documentId: string +): Promise<{ ok: true; editHandle: string; epoch: number } | { ok: false }> { + const gp = await deps.authService.verifyEditUcan(editUcan, documentId); + if (gp?.kind !== "actor") return { ok: false }; + const minEpoch = await deps.mongodbStore.getMinEditEpoch(documentId); + if (gp.epoch < minEpoch) return { ok: false }; + return { ok: true, editHandle: gp.editHandle, epoch: gp.epoch }; +} + +// Live per-actor edit-admission re-check against the ADMITTED context stamped at JOIN. For the +// gp-actor rail this is the offline epoch-floor: a rotation that advanced minEditEpoch strands the +// stale socket (its admitted editEpoch < floor); session-termination also confines it. No gate poll. export async function isStillAdmitted(socket: AppSocket, deps: SocketHandlerDeps): Promise { - const { rail, railKind, documentId, sessionDid, actorHandle } = socket.data; + const { rail, railKind, documentId, sessionDid, editEpoch } = 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 (editEpoch === undefined) return false; + return editEpoch >= (await deps.mongodbStore.getMinEditEpoch(documentId)); } if (rail === "workspace") return (await deps.sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) === true; return (await deps.sessionManager.getCollabJoinEnabled(documentId, sessionDid)) === true; @@ -1057,9 +1062,9 @@ export async function handleSnapshot( }); } - // floorSeq is client-asserted; an honest client's floor comes from served rows, so it - // can never exceed the document's seq counter. A floor ahead of the counter would hide - // every update row up to it from hydration — reject rather than store a hiding floor. + // floorSeq is client-supplied and derived from served rows, so it can never legitimately + // exceed the document's seq counter. A floor ahead of the counter would hide every update + // row up to it from hydration — reject rather than store a hiding floor. const currentSeq = await mongodbStore.getCurrentSeq(documentId); if (floorSeq > currentSeq) { return callback({ diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts index 61e0860..514ece2 100644 --- a/src/tests/services/flush-route.test.ts +++ b/src/tests/services/flush-route.test.ts @@ -14,8 +14,11 @@ describe("POST /flush", () => { beforeEach(() => { vi.clearAllMocks(); deps = { - authService: { verifyCollaborationToken: vi.fn() }, - mongodbStore: { createUpdate: vi.fn().mockResolvedValue({ id: "u1", seq: 5 }) }, + authService: { verifyCollaborationToken: vi.fn(), verifyEditUcan: vi.fn() }, + mongodbStore: { + createUpdate: vi.fn().mockResolvedValue({ id: "u1", seq: 5 }), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + }, }; }); @@ -35,6 +38,8 @@ describe("POST /flush", () => { expect(deps.mongodbStore.createUpdate).not.toHaveBeenCalled(); }); + // No editUcan on the body (public/workspace/owner rails) → the belt is skipped and the delta + // persists as before. Regression guard: the belt must not gate rails that never carry a claim. it("persists a verified delta through createUpdate (seq + durable gate apply there)", async () => { deps.authService.verifyCollaborationToken.mockResolvedValue(true); const r = res(); @@ -46,6 +51,39 @@ describe("POST /flush", () => { expect(r.status).toHaveBeenCalledWith(200); }); + // When the beacon carries a gp-actor editUcan, the durable-write path re-runs the same offline + // admission JOIN does — a revoked/demoted claim (below the floor, or no longer an actor claim) + // is refused here too. + it("403s a gp-actor editUcan whose epoch is below the doc's minEditEpoch floor", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + deps.authService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 1 }); + deps.mongodbStore.getMinEditEpoch.mockResolvedValue(2); // a rotation advanced the floor past the claim + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct", editUcan: "revoked" } } as any, r); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.mongodbStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("403s an editUcan that no longer verifies as an actor claim", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + deps.authService.verifyEditUcan.mockResolvedValue(null); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct", editUcan: "bad" } } as any, r); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.mongodbStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("persists a delta when the editUcan is at/above the floor", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + deps.authService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 3 }); + deps.mongodbStore.getMinEditEpoch.mockResolvedValue(2); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct", editUcan: "good" } } as any, r); + expect(deps.authService.verifyEditUcan).toHaveBeenCalledWith("good", "d"); + expect(deps.mongodbStore.createUpdate).toHaveBeenCalled(); + 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()); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index 86e629d..9640223 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -456,23 +456,21 @@ describe("createEvictWorkspaceMemberHandler", () => { }); describe("createEvictEditActorsHandler", () => { - let deps: any, io: any, editBoundCache: any; + let deps: any, io: 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, mongodbStore: { setMinEditEpoch: vi.fn() }, }; io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; }); - it("busts the cache and disconnects ONLY matching gp-actor sockets", async () => { + it("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() }; @@ -483,7 +481,6 @@ describe("createEvictEditActorsHandler", () => { 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(); @@ -523,13 +520,12 @@ describe("createEvictEditActorsHandler", () => { expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 0, dropped: 0 }); }); - it("403s a non-owner without touching cache or sockets", async () => { + it("403s a non-owner without touching 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); }); @@ -552,14 +548,13 @@ describe("createEvictEditActorsHandler", () => { expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); }); - it("does not stamp minEditEpoch when gateEpoch is NaN, but the evict still proceeds", async () => { + it("does not stamp minEditEpoch when gateEpoch is NaN, but the drop 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); }); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index 5873552..df24bdc 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -80,7 +80,6 @@ describe("handleAuth", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { @@ -1746,7 +1745,6 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, - editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { @@ -1839,7 +1837,6 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () expect(cb).toHaveBeenCalledWith( expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) ); - expect(deps.editBoundCache.check).not.toHaveBeenCalled(); expect(socket.data.rail).toBeUndefined(); }); @@ -1847,7 +1844,6 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () 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); @@ -1855,6 +1851,7 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () expect(socket.data.rail).toBe("gp"); expect(socket.data.railKind).toBe("gp-actor"); expect(socket.data.actorHandle).toBe("h1"); + expect(socket.data.editEpoch).toBe(2); }); }); diff --git a/src/tests/services/socket-handlers.handleAwareness.test.ts b/src/tests/services/socket-handlers.handleAwareness.test.ts index a1965c7..e31c5a3 100644 --- a/src/tests/services/socket-handlers.handleAwareness.test.ts +++ b/src/tests/services/socket-handlers.handleAwareness.test.ts @@ -30,15 +30,14 @@ function createFakeSocket( } as unknown as AppSocket; } -function createDeps(check = vi.fn().mockResolvedValue("bound")) { +function createDeps() { return { authService: {} as any, sessionManager: { getWorkspaceEditEnabled: vi.fn().mockResolvedValue(true), getCollabJoinEnabled: vi.fn().mockResolvedValue(true), } as any, - mongodbStore: {} as any, - editBoundCache: { check } as any, + mongodbStore: { getMinEditEpoch: vi.fn().mockResolvedValue(0) } as any, }; } @@ -102,8 +101,9 @@ describe('handleAwareness', () => { }); 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" }); + const deps = createDeps(); + deps.mongodbStore.getMinEditEpoch.mockResolvedValue(2); // floor advanced past the socket's editEpoch + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", editEpoch: 1 }); 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 @@ -111,28 +111,26 @@ describe('handleAwareness', () => { }); it("never re-checks the owner", async () => { - const check = vi.fn(); - const deps = createDeps(check); + const deps = createDeps(); 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(deps.mongodbStore.getMinEditEpoch).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" }); + const deps = createDeps(); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", editEpoch: 2 }); 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); + expect(deps.mongodbStore.getMinEditEpoch).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" }); + const deps = createDeps(); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", editEpoch: 2 }); await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); await flush(); expect((socket as any).disconnect).not.toHaveBeenCalled(); diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts index 7bf5155..245037f 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, - 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 9c20f8e..4310886 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, - 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 a243a53..735d4ab 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, - 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 a5deefc..f6c3cf1 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -52,7 +52,6 @@ describe("handleDocumentUpdate", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, - editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts index e6168e2..3e4c82e 100644 --- a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts @@ -16,8 +16,10 @@ describe("handleMirrorSnapshot", () => { vi.clearAllMocks(); deps = { sessionManager: { getRuntimeSession: vi.fn().mockResolvedValue({ sessionDid: "sess-1" }) }, - mongodbStore: { upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined) }, - editBoundCache: { check: vi.fn() }, + mongodbStore: { + upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + }, }; }); @@ -58,9 +60,10 @@ describe("handleMirrorSnapshot", () => { }); it("disconnects the socket after the EDIT_REVOKED 403", async () => { - deps.editBoundCache.check.mockResolvedValue("unbound"); + // A rotation advanced the floor past this socket's admitted editEpoch → not admitted. + deps.mongodbStore.getMinEditEpoch.mockResolvedValue(2); const cb = vi.fn(); - const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", editEpoch: 1 }); await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); expect(cb).toHaveBeenCalledWith( expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED }) @@ -70,9 +73,9 @@ describe("handleMirrorSnapshot", () => { }); it("does not disconnect an admitted editor", async () => { - deps.editBoundCache.check.mockResolvedValue("bound"); + deps.mongodbStore.getMinEditEpoch.mockResolvedValue(2); const cb = vi.fn(); - const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", editEpoch: 2 }); await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); expect((s as any).disconnect).not.toHaveBeenCalled(); expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index ade004c..c54be06 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, - 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 518ddda..0f95f1d 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -21,7 +21,6 @@ describe("handleSnapshot", () => { authService: { verifyCollaborationToken } as any, sessionManager: { getRuntimeSession, getCollabJoinEnabled } as any, mongodbStore: { createSnapshot, getCurrentSeq } as any, - editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index ee46de4..7efcc0f 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, - 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 76d915a..f732682 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, - editBoundCache: {} as any, }; beforeEach(() => { diff --git a/src/tests/utils/contract.test.ts b/src/tests/utils/contract.test.ts index 362522b..1f1e353 100644 --- a/src/tests/utils/contract.test.ts +++ b/src/tests/utils/contract.test.ts @@ -85,3 +85,55 @@ describe("getPortalOwnerAddress", () => { ); }); }); + +describe("getOwnerDid negative caching", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("caches a confirmed non-collaborator (empty result) so repeat lookups skip the RPC", async () => { + const spy = vi.spyOn(contract.publicClient, "readContract").mockResolvedValue("" as any); + const portal = "0x9a11000000000000000000000000000000000001" as any; + const addr = "0x9a11000000000000000000000000000000000002" as any; + + const first = await contract.getOwnerDid(portal, addr); + const second = await contract.getOwnerDid(portal, addr); + + expect(first).toBe(""); + expect(second).toBe(""); + // One miss reads the chain (legacy + v2 = 2 reads); the cached "" serves the rest. + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("does NOT cache an RPC failure — the next lookup retries the chain", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockRejectedValue(new Error("rate limited")); + const portal = "0x9b22000000000000000000000000000000000003" as any; + const addr = "0x9b22000000000000000000000000000000000004" as any; + + const first = await contract.getOwnerDid(portal, addr); + const second = await contract.getOwnerDid(portal, addr); + + expect(first).toBeNull(); + expect(second).toBeNull(); + // A caught read returns null (indistinguishable from a transient blip), so it + // must never be cached — both misses re-read the chain (2 reads each). + expect(spy).toHaveBeenCalledTimes(4); + }); + + it("refreshOwnerDid also does NOT persist a null — the recheck path must not lock the owner out", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockRejectedValue(new Error("rate limited")); + const portal = "0x9c33000000000000000000000000000000000005" as any; + const addr = "0x9c33000000000000000000000000000000000006" as any; + + const refreshed = await contract.refreshOwnerDid(portal, addr); + expect(refreshed).toBeNull(); + // The null was not cached, so a follow-up getOwnerDid re-reads the chain + // (recovers as soon as the RPC heals) instead of serving a stale null. + await contract.getOwnerDid(portal, addr); + expect(spy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index e9600ea..74f27f8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -351,6 +351,7 @@ export interface SocketData { railKind?: "gp-actor" | "workspace" | "public"; actorHandle?: string; actorIdentityDid?: string; + editEpoch?: number; lastAdmitRecheckAt?: number; } @@ -403,7 +404,6 @@ export interface ServerConfig { serverKeyPair?: any; }; gate: { - url: string | undefined; did: string | undefined; }; rateLimit: { diff --git a/src/utils/contract.ts b/src/utils/contract.ts index afa2fac..cbeafd7 100644 Binary files a/src/utils/contract.ts and b/src/utils/contract.ts differ