From 7c292ab41daa5bf2b9b0fb290cb2c982d7aa0118 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Fri, 24 Jul 2026 13:15:58 +0530 Subject: [PATCH 1/4] refactor: make token-based edit admission fully offline Edit admission for token-based editors no longer makes a network call to the gate. It now runs entirely offline: verify the gate-signed edit token and compare its epoch against a per-document edit-epoch floor. The floor is advanced on key rotation (which also terminates the superseded session), and the eviction route stamps the floor and drops the matching sockets directly. Removes the now-unused gate URL configuration and the cache layer it fed. Co-Authored-By: Claude Opus 4.8 --- src/config/index.ts | 1 - src/index.ts | 9 +-- src/services/gate-epoch.ts | 69 ------------------- src/services/owner-op-routes.ts | 10 ++- src/services/socket-handlers.deps.ts | 2 - src/services/socket-handlers.ts | 67 +++++++++--------- src/tests/services/owner-op-routes.test.ts | 13 ++-- .../socket-handlers.handleAuth.test.ts | 5 +- .../socket-handlers.handleAwareness.test.ts | 26 ++++--- ...ocket-handlers.handleCommitHistory.test.ts | 1 - ...ocket-handlers.handleDisconnecting.test.ts | 1 - ...cket-handlers.handleDocumentCommit.test.ts | 1 - ...cket-handlers.handleDocumentUpdate.test.ts | 1 - ...cket-handlers.handleMirrorSnapshot.test.ts | 15 ++-- ...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 | 2 +- 19 files changed, 65 insertions(+), 162 deletions(-) delete mode 100644 src/services/gate-epoch.ts 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/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..f527ecf 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. See docs/architecture/edit-permission.md §6.2. + 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,31 @@ 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. +// 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. See +// docs/architecture/edit-permission.md §6.2 (epoch-floor admission). +export async function resolveEditAdmission( + deps: Pick, + 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; 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/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: { From 8e06b65d92ba8f6123ab99084833c5163ce2e30e Mon Sep 17 00:00:00 2001 From: Nadeem Date: Fri, 24 Jul 2026 14:53:21 +0530 Subject: [PATCH 2/4] feat(flush): re-check edit authorization before persisting a flushed delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /flush beacon now carries the caller's edit token and re-runs the same offline admission check used at socket join (token validity + document edit-epoch) before writing. Callers without an edit token — public and workspace editors, and owners — are unchanged. Narrows resolveEditAdmission's dependencies to a small EditAdmissionDeps type so the flush route can reuse it without pulling in the full socket-handler surface. Co-Authored-By: Claude Opus 4.8 --- src/services/flush-route.ts | 20 ++++++++++-- src/services/socket-handlers.ts | 9 +++++- src/tests/services/flush-route.test.ts | 42 ++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index afc5b58..c5fbac1 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,19 @@ export function createFlushHandler(deps: FlushDeps) { return; } + // H3(a) belt: 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/revoked claim is refused on the durable-write + // path too — covering the rotation-deferred and pre-cutover window. Honest-client defense in + // depth; the hard gate stays rotation + session-termination. 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/socket-handlers.ts b/src/services/socket-handlers.ts index f527ecf..3daf6dd 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -614,12 +614,19 @@ export async function handleAuth( } } +// 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. See // docs/architecture/edit-permission.md §6.2 (epoch-floor admission). export async function resolveEditAdmission( - deps: Pick, + deps: EditAdmissionDeps, editUcan: string, documentId: string ): Promise<{ ok: true; editHandle: string; epoch: number } | { ok: false }> { diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts index 61e0860..125764b 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); }); + // H3(a) belt: 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, covering the rotation-deferred / pre-cutover window. + 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()); From 6a1efca56df47ef298e15f78d9955795032d36aa Mon Sep 17 00:00:00 2001 From: Nadeem Date: Fri, 24 Jul 2026 18:18:41 +0530 Subject: [PATCH 3/4] fix(auth): negative-cache confirmed non-collaborators, never read failures getOwnerDid re-hit the RPC on every unregistered/garbage lookup because a cached empty result was treated as a miss. Cache a confirmed-absent result on a short TTL so repeat lookups skip the RPC, but never cache a null: a caught read is indistinguishable from a rate-limited blip, and caching it would lock the real owner out for the TTL. Both getOwnerDid and refreshOwnerDid route through one helper so the two writers cannot diverge. Co-Authored-By: Claude Opus 4.8 --- src/tests/utils/contract.test.ts | 52 +++++++++++++++++++++++++++++++ src/utils/contract.ts | Bin 7952 -> 9298 bytes 2 files changed, 52 insertions(+) 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/utils/contract.ts b/src/utils/contract.ts index afa2fac8fd019114abd84cb251ed441c2d117d25..cbeafd7b5f0134513da220daa6ac08ff7e704f3d 100644 GIT binary patch delta 1349 zcmbVMO>0v@6veEhtwK?87Eyke_lCU8q%-ppqlM_Q zJ0b4;6GH!mOINO3=pXUSYg3IP6nFD+KhHV$zTfz={pnk7vbIKB6lm3zrq3ZMtxADT z64B8{IBh7;{ec@(5DTfuuuT(JAZbZ)B%B;_DG)&nN3BRuKMmpqM6|Isjt#Y1&nqMy zS*HgU|Aq%F9IPW@IwIn=bfOGL(!v<8A`FqiQrGA{Y)i3pPO6Ap%s%Sgi<(cvLpEc4 zAEj(vkw&ph7_pUhr7^qGZ}H|DLCpwhIM9)juQ~J)kJ5<@ddax#F%W4KJJ^Vj@+wV| ztj%WQps|O)tjF(fKcJgcOHzfX8KtGt#-fA#S;@l31Z-SNWMBF|JBb{SP+=Cqw*oLm zI!!`KbkIX%(?_}GNf$VyM-Nwd0bE!|JM~7hwbOdpZZ@87wRR2~?b=ptyU~V8d%scJ zZPxdxx5p9dsux1dx0%sobu#p%jue|x zeW#9cbuS%MvQLqae*zW0XzS3QFrlx$NErkdaw>_AW#wpP<-*!4<3c=H$G{$NPZG-e za2ftx0Zzjr0hcUb0M|3N_cz>ya^u-EMcP-Xp(u(WZL?r;7t^PET@o?o}?MM>rSOr^^esrhAJgUQUO1kvdf&cctR+5L2Zc zIg`40T=0m9*<3*$ezb7^_a1@)5+Ew(*s}5J6n>>(N33J^!}CRS-lWa{N=$ydu$v?{ si$7t5yc$TqZ+bi~Proj1UZ=TAr?;2hU7I7}^y5 Date: Fri, 24 Jul 2026 18:42:52 +0530 Subject: [PATCH 4/4] docs(collab): tidy edit-admission code comments Co-Authored-By: Claude Opus 4.8 --- src/services/flush-route.ts | 9 ++++----- src/services/socket-handlers.ts | 11 +++++------ src/tests/services/flush-route.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index c5fbac1..7d7ca38 100644 --- a/src/services/flush-route.ts +++ b/src/services/flush-route.ts @@ -29,11 +29,10 @@ export function createFlushHandler(deps: FlushDeps) { return; } - // H3(a) belt: 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/revoked claim is refused on the durable-write - // path too — covering the rotation-deferred and pre-cutover window. Honest-client defense in - // depth; the hard gate stays rotation + session-termination. Rails without a claim (public/ - // workspace/owner) skip it, mirroring JOIN's `if (args.editUcan)` branch. + // 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) { diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 3daf6dd..927e4c2 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -475,7 +475,7 @@ export async function handleAuth( if (args.editUcan) { // Offline gp-actor admission: valid editUcan + epoch at/above the minEditEpoch floor. // No gate poll — rotation bumps the floor and terminates the old session, confining a - // removed actor. See docs/architecture/edit-permission.md §6.2. + // removed actor. const admission = await resolveEditAdmission(deps, args.editUcan, documentId); if (admission.ok) { rail = "gp"; @@ -623,8 +623,7 @@ export type EditAdmissionDeps = { // 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. See -// docs/architecture/edit-permission.md §6.2 (epoch-floor admission). +// old session, so a removed actor's stale editUcan is rejected offline. export async function resolveEditAdmission( deps: EditAdmissionDeps, editUcan: string, @@ -1063,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 125764b..514ece2 100644 --- a/src/tests/services/flush-route.test.ts +++ b/src/tests/services/flush-route.test.ts @@ -51,9 +51,9 @@ describe("POST /flush", () => { expect(r.status).toHaveBeenCalledWith(200); }); - // H3(a) belt: 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, covering the rotation-deferred / pre-cutover window. + // 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 });