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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 1 addition & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions src/services/flush-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthService, "verifyCollaborationToken">;
mongodbStore: Pick<MongoDBStore, "createUpdate">;
authService: Pick<AuthService, "verifyCollaborationToken" | "verifyEditUcan">;
mongodbStore: Pick<MongoDBStore, "createUpdate" | "getMinEditEpoch">;
}

export function createFlushHandler(deps: FlushDeps) {
return async (req: Request, res: Response): Promise<void> => {
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;
Expand All @@ -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`.
Expand Down
69 changes: 0 additions & 69 deletions src/services/gate-epoch.ts

This file was deleted.

10 changes: 4 additions & 6 deletions src/services/owner-op-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<MongoDBStore, "setMinEditEpoch"> },
deps: OwnerOpDeps & { mongodbStore: Pick<MongoDBStore, "setMinEditEpoch"> },
io: AppServer
) {
return async (req: Request, res: Response): Promise<void> => {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 0 additions & 2 deletions src/services/socket-handlers.deps.ts
Original file line number Diff line number Diff line change
@@ -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;
}
79 changes: 42 additions & 37 deletions src/services/socket-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,7 +36,6 @@ const defaultDeps: SocketHandlerDeps = {
authService,
sessionManager,
mongodbStore,
editBoundCache,
};

function validateHexAddress(address: string | undefined, fieldName: string): address is Hex {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<SocketHandlerDeps["authService"], "verifyEditUcan">;
mongodbStore: Pick<SocketHandlerDeps["mongodbStore"], "getMinEditEpoch">;
};

// 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<boolean> {
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;
Expand Down Expand Up @@ -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({
Expand Down
42 changes: 40 additions & 2 deletions src/tests/services/flush-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
};
});

Expand All @@ -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();
Expand All @@ -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());
Expand Down
Loading
Loading