diff --git a/package.json b/package.json index e36351f..3460504 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "start": "node dist/index.js", "start:publish-reconcile": "node dist/cron/publishReconcile.js", "dev:publish-reconcile": "tsx watch src/cron/publishReconcile.ts", + "start:grace-purge": "node dist/cron/gracePurge.js", + "dev:grace-purge": "tsx watch src/cron/gracePurge.ts", "clean": "rm -rf dist", "format": "prettier --write .", "lint": "eslint src/**/*.ts", diff --git a/src/config/index.ts b/src/config/index.ts index d46a45a..3c3914b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -37,7 +37,19 @@ export const config: ServerConfig = { interval: process.env.PUBLISH_RECONCILE_INTERVAL || "15 minutes", batchSize: parseInt(process.env.PUBLISH_RECONCILE_BATCH || "500"), }, + deleteGrace: { + windowMs: parseInt(process.env.DELETE_GRACE_WINDOW_MS || "2592000000"), // 30 days + interval: process.env.DELETE_GRACE_INTERVAL || "1 hour", + batchSize: parseInt(process.env.DELETE_GRACE_BATCH || "200"), + }, agenda: { concurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY || "1"), }, + gate: { + url: process.env.GATE_URL, + did: process.env.GATE_DID, + }, + webhook: { + apiKey: process.env.COLLAB_WEBHOOK_API_KEY, + }, }; \ No newline at end of file diff --git a/src/cron/gracePurge.ts b/src/cron/gracePurge.ts new file mode 100644 index 0000000..020cabe --- /dev/null +++ b/src/cron/gracePurge.ts @@ -0,0 +1,26 @@ +import { agenda } from "./agenda.js"; +import { databaseService } from "../database/index.js"; +import gracePurgeJob from "./jobs/gracePurgeJob.js"; + +async function graceful() { + await agenda.stop(); + await databaseService.disconnect(); + process.exit(0); +} + +(async () => { + try { + // The job queries mongoose models, so this worker needs its own DB connection + // (Agenda's Mongo connection only backs the jobs collection). + await databaseService.connect(); + await agenda.start(); + await gracePurgeJob.setupJob(); + console.log("grace-purge worker started"); + } catch (err) { + console.error(err); + await graceful(); + } +})(); + +process.on("SIGTERM", graceful); +process.on("SIGINT", graceful); diff --git a/src/cron/jobs/gracePurgeJob.ts b/src/cron/jobs/gracePurgeJob.ts new file mode 100644 index 0000000..cd7a845 --- /dev/null +++ b/src/cron/jobs/gracePurgeJob.ts @@ -0,0 +1,25 @@ +import type { Job } from "agenda"; +import { agenda } from "../agenda.js"; +import { config } from "../../config/index.js"; +import { mongodbStore } from "../../services/mongodb-store.js"; + +const jobName = "DELETE_GRACE_PURGE"; + +export async function jobDefinition(job: Job, done: (err?: Error) => void) { + try { + const cutoff = Date.now() - config.deleteGrace.windowMs; + const purged = await mongodbStore.purgeTombstonedBefore(cutoff, config.deleteGrace.batchSize); + console.log(`[${jobName}] purged=${purged.length}`); + done(); + } catch (err: any) { + console.error(`[${jobName}] ${err?.message ?? err}`); + done(err); + } +} + +async function setupJob() { + agenda.define(jobName, jobDefinition); + await agenda.every(config.deleteGrace.interval, jobName); +} + +export default { setupJob }; diff --git a/src/database/models/document-edit-epoch.ts b/src/database/models/document-edit-epoch.ts new file mode 100644 index 0000000..a2f34a0 --- /dev/null +++ b/src/database/models/document-edit-epoch.ts @@ -0,0 +1,20 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +// Per-document edit-admission floor. A gp-actor editUcan whose `epoch` fact is below this +// is refused at JOIN (offline revocation — replaces the runtime edit-bound poll). Bumped by +// the rotation / evict path. See docs/architecture/gp-semaphore.md. +interface IDocumentEditEpoch extends MongooseDocument { + _id: string; // documentId + minEditEpoch: number; +} + +const DocumentEditEpochSchema = new Schema({ + _id: { type: String, required: true }, + minEditEpoch: { type: Number, required: true, default: 0 }, +}); + +export const DocumentEditEpochModel = mongoose.model( + "DocumentEditEpoch", + DocumentEditEpochSchema +); +export type { IDocumentEditEpoch }; diff --git a/src/database/models/document-meta.ts b/src/database/models/document-meta.ts index a0e33f6..e5a8db2 100644 --- a/src/database/models/document-meta.ts +++ b/src/database/models/document-meta.ts @@ -10,6 +10,8 @@ interface IDocumentMeta extends MongooseDocument { title: string | null; // roomKey-encrypted updatedAt: number; isPublished: boolean; // set by the publish reconciler once the doc is on-chain + tombstonedAt: number | null; // set by the DeletedFile webhook; grace window runs from here + tombstoneReason: string | null; } const DocumentMetaSchema = new Schema({ @@ -22,6 +24,8 @@ const DocumentMetaSchema = new Schema({ title: { type: String, default: null }, updatedAt: { type: Number, required: true }, isPublished: { type: Boolean, default: false }, + tombstonedAt: { type: Number, default: null }, + tombstoneReason: { type: String, default: null }, }); // Discovery: "all docs bound to this identity / portal owner" for list-my-documents. @@ -29,6 +33,8 @@ const DocumentMetaSchema = new Schema({ // reconciler's candidate scan are index-served. DocumentMetaSchema.index({ ownerIdentityDid: 1, isPublished: 1 }, { background: true }); DocumentMetaSchema.index({ ownerDid: 1 }, { background: true }); +// Grace-window sweep: docs eligible for irreversible purge (see docs/architecture/edit-permission.md). +DocumentMetaSchema.index({ tombstonedAt: 1 }, { background: true }); export const DocumentMetaModel = mongoose.model("DocumentMeta", DocumentMetaSchema); export type { IDocumentMeta }; diff --git a/src/database/models/document-mirror.ts b/src/database/models/document-mirror.ts new file mode 100644 index 0000000..8a4f491 --- /dev/null +++ b/src/database/models/document-mirror.ts @@ -0,0 +1,25 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +// The VIEW plane: fileKey-encrypted full-state snapshots (published-artifact shape) that +// viewers read load-on-open. Separate from DocumentUpdate (roomKey durability lane): different +// key, open read, epoch-keyed. One row per (documentId, fileKeyEpoch), keep-latest. +interface IDocumentMirror extends MongooseDocument { + documentId: string; + fileKeyEpoch: number; + data: string; + createdAt: number; + authorSessionDid: string; +} + +const DocumentMirrorSchema = new Schema({ + documentId: { type: String, required: true }, + fileKeyEpoch: { type: Number, required: true }, + data: { type: String, required: true }, + createdAt: { type: Number, required: true }, + authorSessionDid: { type: String, required: true }, +}); + +DocumentMirrorSchema.index({ documentId: 1, fileKeyEpoch: 1 }, { unique: true, background: true }); + +export const DocumentMirrorModel = mongoose.model("DocumentMirror", DocumentMirrorSchema); +export type { IDocumentMirror }; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index eaf1e52..322c44b 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -3,3 +3,5 @@ export { DocumentCommitModel, type IDocumentCommit } from "./document-commit"; export { SessionModel, type ISession } from "./session"; export { CounterModel, type ICounter } from "./counter"; export { DocumentMetaModel, type IDocumentMeta } from "./document-meta"; +export { DocumentMirrorModel, type IDocumentMirror } from "./document-mirror"; +export { DocumentEditEpochModel, type IDocumentEditEpoch } from "./document-edit-epoch"; diff --git a/src/database/models/session.ts b/src/database/models/session.ts index 2948afb..804101d 100644 --- a/src/database/models/session.ts +++ b/src/database/models/session.ts @@ -11,6 +11,7 @@ export interface ISession extends MongooseDocument { ownerIdentityDid: string | null; portalAddress: string | null; collabJoinEnabled?: boolean; + workspaceEditEnabled?: boolean; } const SessionSchema = new Schema({ @@ -26,6 +27,7 @@ const SessionSchema = new Schema({ ownerIdentityDid: { type: String, default: null }, portalAddress: { type: String, default: null }, collabJoinEnabled: { type: Boolean }, // intentionally no default + workspaceEditEnabled: { type: Boolean }, // intentionally no default }); SessionSchema.index({ documentId: 1, createdAt: 1, sessionDid: 1 }); diff --git a/src/index.ts b/src/index.ts index a9c9fe1..530e150 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import compression from "compression"; import { createServer } from "http"; import { config } from "./config"; import { authService } from "./services/auth"; -import { registerEventHandlers } from "./services/socket-handlers"; +import { registerEventHandlers, getRoomName } from "./services/socket-handlers"; import { authMiddleware } from "./services/auth-middleware"; import { sessionManager } from "./services/session-manager"; import { mongodbStore } from "./services/mongodb-store"; @@ -15,10 +15,19 @@ import { createRedisAdapter } from "./redis"; import { databaseService } from "./database"; import { createCollabJoinEnabledHandler, + createWorkspaceEditTierHandler, + createEvictEditActorsHandler, + createEvictWorkspaceMemberHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, + createMirrorReadHandler, + createShareContextHandler, } from "./services/owner-op-routes"; +import { createRotateSessionHandler } from "./services/rotate-route"; +import { rotationCoordinator } from "./services/rotation-coordinator"; +import { editBoundCache } from "./services/gate-epoch"; import { createFlushHandler } from "./services/flush-route"; +import { createDeletedFileWebhookHandler } from "./services/deleted-file-webhook"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; @@ -95,6 +104,58 @@ class CollaborationServer { "/documents/:documentId/collab-join-enabled", createCollabJoinEnabledHandler({ authService, sessionManager }, this.io) ); + this.app.post( + "/documents/:documentId/workspace-edit-tier", + createWorkspaceEditTierHandler({ authService, sessionManager }, this.io) + ); + this.app.post( + "/documents/:documentId/evict-edit-actors", + createEvictEditActorsHandler({ authService, sessionManager, editBoundCache, mongodbStore }, this.io) + ); + this.app.post( + "/workspaces/:portalAddress/evict-member", + createEvictWorkspaceMemberHandler({ authService, sessionManager }, this.io) + ); + this.app.post( + "/documents/:documentId/rotate-session", + createRotateSessionHandler({ + authService, sessionManager, mongodbStore, rotationCoordinator, + terminateOldSession: async (documentId, sessionDid, appType) => { + const room = getRoomName(documentId, sessionDid); + const sockets = await this.io!.in(room).fetchSockets(); + // Laggard sockets stay AUTHED: their next write must reach createUpdate and get the + // D-11 SESSION_TERMINATED 409 — that ack is their self-heal signal. + // De-authing here would surface a 401 first and strand them frozen. + // See docs/architecture/gp-semaphore.md. + for (const s of sockets) { s.leave(room); } + await sessionManager.deactivateSession(documentId, sessionDid); + await sessionManager.terminateSession(documentId, sessionDid, appType); + }, + }, this.io) + ); + this.app.get( + "/documents/:documentId/mirror", + createMirrorReadHandler({ mongodbStore }) + ); + this.app.get( + "/documents/:documentId/share-context", + createShareContextHandler({ mongodbStore, sessionManager }) + ); + this.app.post( + "/webhooks/file-deleted", + createDeletedFileWebhookHandler({ + mongodbStore, + onTombstoned: async (documentId) => { + const sessions = await sessionManager.getNonTerminatedSessionsForDocument(documentId); + for (const s of sessions) { + const room = getRoomName(documentId, s.sessionDid); + this.io!.to(room).emit("/session/terminated", { roomId: documentId }); + for (const sock of await this.io!.in(room).fetchSockets()) sock.leave(room); + await sessionManager.terminateSession(documentId, s.sessionDid, s.appType ?? "ddoc"); + } + }, + }) + ); this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); this.app.delete( @@ -124,6 +185,18 @@ class CollaborationServer { async start() { try { + if (!config.gate.did) { + console.warn( + "[startup] GATE_DID is not set — GP (private/group) live editing is DISABLED; only owner/workspace/public rails admit writes." + ); + } else if (!config.gate.url) { + // GATE_DID without GATE_URL: verifyEditUcan admits GP joins but the 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 await databaseService.connect(); diff --git a/src/services/auth.ts b/src/services/auth.ts index 4313f31..184370e 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,14 +1,20 @@ import * as ucans from "@ucans/ucans"; -import { getIdentitySigningDid, getOwnerDid } from "../utils/contract"; +import { getIdentitySigningDid, getOwnerDid, refreshOwnerDid } from "../utils/contract"; import { Hex } from "viem"; import NodeCache from "node-cache"; +import { config } from "../config"; export class AuthService { private serverDid: string; + private gateDid?: string; private collaborationTokenCache = new NodeCache({ stdTTL: 3600 }); + // Rate-limits the mismatch-triggered fresh chain read so garbage tokens + // can't hammer the RPC. + private ownerDidRecheckMemo = new NodeCache({ stdTTL: 10 }); - constructor(serverDid: string) { + constructor(serverDid: string, gateDid?: string) { this.serverDid = serverDid; + this.gateDid = gateDid; } getServerDid(): string { @@ -17,32 +23,49 @@ export class AuthService { async verifyOwnerToken(token: string, contractAddress: Hex, collaboratorAddress: Hex) { try { - const ownerDid = await getOwnerDid(contractAddress, collaboratorAddress); - if (!ownerDid) return null; - - const result = await ucans.verify(token, { - audience: this.serverDid, - requiredCapabilities: [ - { - capability: { - with: { scheme: "storage", hierPart: contractAddress.toLowerCase() }, - can: { namespace: "collaboration", segments: ["CREATE"] }, - }, - rootIssuer: ownerDid, - }, - ], - }); - - if (result.ok) { - return ownerDid; + const cachedDid = await getOwnerDid(contractAddress, collaboratorAddress); + if (this.isDid(cachedDid) && (await this.ucanVerifiesAgainstOwner(token, contractAddress, cachedDid))) { + return cachedDid; } - return null; + // The cached DID may predate a workspace rotation (member removal + // re-registers the collab DID) — one rate-limited fresh chain read, + // then a single re-verify. + const memoKey = `recheck-${contractAddress}-${collaboratorAddress}`; + if (this.ownerDidRecheckMemo.get(memoKey)) return null; + this.ownerDidRecheckMemo.set(memoKey, true); + const freshDid = await refreshOwnerDid(contractAddress, collaboratorAddress); + if (!this.isDid(freshDid) || freshDid === cachedDid) return null; + return (await this.ucanVerifiesAgainstOwner(token, contractAddress, freshDid)) ? freshDid : null; } catch (error) { console.error("UCAN verification error:", error); return null; } } + private isDid(value: unknown): value is string { + // Reject anything that isn't a DID string before it reaches ucans.verify + // (which throws a TypeError on a malformed rootIssuer instead of failing + // the verification) — an unregistered or mis-decoded collaborator must + // 401 cleanly. + return typeof value === "string" && value.startsWith("did:"); + } + + private async ucanVerifiesAgainstOwner(token: string, contractAddress: Hex, rootIssuer: string): Promise { + const result = await ucans.verify(token, { + audience: this.serverDid, + requiredCapabilities: [ + { + capability: { + with: { scheme: "storage", hierPart: contractAddress.toLowerCase() }, + can: { namespace: "collaboration", segments: ["CREATE"] }, + }, + rootIssuer, + }, + ], + }); + return result.ok; + } + async verifyCollaborationToken(token: string, sessionDid: string, documentId: string) { const cacheKey = `${token}:${sessionDid}:${documentId}`; const cachedResult = this.collaborationTokenCache.get(cacheKey); @@ -75,13 +98,59 @@ export class AuthService { } } - async verifyIdentityToken( + /** + * Verify a gate-minted edit-admission UCAN, rooted at the pinned gate DID. Returns the + * signed `editHandle` fact, or null. It comes from the token, never a client arg. + */ + async verifyEditUcan( token: string, - identityContractAddress: Hex, - ddocId: string - ): Promise { + documentId: string + ): Promise<{ kind: "actor"; editHandle: string; epoch: number } | null> { + if (!this.gateDid) return null; // GP editing disabled until GATE_DID is pinned + try { + const result = await ucans.verify(token, { + audience: this.serverDid, + requiredCapabilities: [ + { + capability: { + with: { scheme: "collab", hierPart: documentId }, + can: { namespace: "collab", segments: ["EDIT"] }, + }, + rootIssuer: this.gateDid, + }, + ], + }); + if (!result.ok) return null; + + const parsed = await ucans.validate(token); + const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { + docId?: string; + editHandle?: string; + epoch?: number; + }; + if (fact.docId !== documentId) return null; + if (typeof fact.editHandle === "string") { + return { kind: "actor", editHandle: fact.editHandle, epoch: typeof fact.epoch === "number" ? fact.epoch : 0 }; + } + return null; + } catch (error) { + console.error("Edit UCAN verification error:", error); + return null; + } + } + + // `hierPart` is the UCAN capability scope the token must be signed for: a documentId for + // per-document owner-ops, or a portalAddress for the list-my-documents discovery route. + async verifyIdentityToken(token: string, hierPart: string): Promise { try { - const signingDid = await getIdentitySigningDid(identityContractAddress); + // Peek the signed fact (validate checks the token's own signature, not rootIssuer). + const parsed = await ucans.validate(token); + const fact = ((parsed.payload.fct ?? [])[0] ?? {}) as { identityContractAddress?: string }; + const addr = fact.identityContractAddress; + if (!addr || !addr.startsWith("0x")) return null; // fail closed: no signed address + + // The on-chain read is the anchor — do NOT trust the token's iss directly. + const signingDid = await getIdentitySigningDid(addr as Hex); if (!signingDid) return null; const result = await ucans.verify(token, { @@ -89,7 +158,7 @@ export class AuthService { requiredCapabilities: [ { capability: { - with: { scheme: "storage", hierPart: ddocId }, + with: { scheme: "storage", hierPart }, can: { namespace: "collaboration", segments: ["OWN"] }, }, rootIssuer: signingDid, @@ -108,18 +177,13 @@ export class AuthService { boundOwnerIdentityDid: string | null; boundOwnerDid: string | null; identityToken?: string; - identityContractAddress?: Hex; ownerToken?: string; ownerAddress?: Hex; portalAddress?: Hex; }): Promise { // Path 1 — bound identity (creator). The `=== boundOwnerIdentityDid` compare is MANDATORY. - if (params.identityToken && params.identityContractAddress && params.boundOwnerIdentityDid) { - const signingDid = await this.verifyIdentityToken( - params.identityToken, - params.identityContractAddress, - params.ddocId - ); + if (params.identityToken && params.boundOwnerIdentityDid) { + const signingDid = await this.verifyIdentityToken(params.identityToken, params.ddocId); if (signingDid && signingDid === params.boundOwnerIdentityDid) return true; } @@ -138,5 +202,6 @@ export class AuthService { } export const authService = new AuthService( - process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + process.env.SERVER_DID || "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + config.gate.did ); diff --git a/src/services/deleted-file-webhook.ts b/src/services/deleted-file-webhook.ts new file mode 100644 index 0000000..a19d649 --- /dev/null +++ b/src/services/deleted-file-webhook.ts @@ -0,0 +1,36 @@ +import type { Request, Response } from "express"; +import type { MongoDBStore } from "./mongodb-store"; +import { DocumentMetaModel } from "../database/models"; +import { config } from "../config"; + +export interface DeletedFileWebhookDeps { + mongodbStore: Pick; + onTombstoned?: (documentId: string) => Promise; // drop live sessions +} + +// Reversible tombstone from the on-chain DeletedFile event — see +// docs/architecture/edit-permission.md. Portal-matches the webhook payload against +// the immutable pin (DocumentMeta.portalAddress) so a colliding appFileId minted +// under an attacker-controlled portal cannot tombstone another owner's document. +export function createDeletedFileWebhookHandler(deps: DeletedFileWebhookDeps) { + return async (req: Request, res: Response): Promise => { + const secret = req.header("x-webhook-api-key"); + if (!config.webhook.apiKey || secret !== config.webhook.apiKey) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const { appFileId, portalAddress } = req.body || {}; + if (typeof appFileId !== "string" || typeof portalAddress !== "string") { + res.status(400).json({ error: "appFileId and portalAddress are required" }); + return; + } + const meta = await DocumentMetaModel.findById(appFileId).select("portalAddress").lean(); + if (!meta || (meta.portalAddress ?? "").toLowerCase() !== portalAddress.toLowerCase()) { + res.status(200).json({ ok: true, matched: false }); // idempotent no-op + return; + } + const done = await deps.mongodbStore.tombstoneDocument(appFileId, "onchain-delete"); + if (done && deps.onTombstoned) await deps.onTombstoned(appFileId); + res.status(200).json({ ok: true, matched: true }); + }; +} diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts index 2b02e51..afc5b58 100644 --- a/src/services/flush-route.ts +++ b/src/services/flush-route.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import type { AuthService } from "./auth"; -import type { MongoDBStore } from "./mongodb-store"; +import { SessionTerminatedError, type MongoDBStore } from "./mongodb-store"; export const FLUSH_MAX_BYTES = 2 * 1024 * 1024; // a final delta is small; cap well under the 10MB body limit @@ -28,20 +28,28 @@ export function createFlushHandler(deps: FlushDeps) { return; } - // Same chokepoint as the socket path: createUpdate applies seq + the durable-write gate; a row is - // persisted only for a bound, non-terminated room. Zero-knowledge — the server never reads `data`. - await deps.mongodbStore.createUpdate({ - id: uuidv4(), - documentId, - data, - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt: Date.now(), - sessionDid, - appType: "ddoc", - }); - + // Per-actor edit admission runs at socket JOIN; this durable-write path additionally + // refuses a terminated (rotated-away) session so a lingering old-session client cannot + // persist. Zero-knowledge — the server never reads `data`. + try { + await deps.mongodbStore.createUpdate({ + id: uuidv4(), + documentId, + data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: Date.now(), + sessionDid, + appType: "ddoc", + }); + } catch (err) { + if (err instanceof SessionTerminatedError) { + res.status(409).json({ error: "Session terminated" }); + return; + } + throw err; + } res.status(200).json({ ok: true }); }; } diff --git a/src/services/gate-epoch.ts b/src/services/gate-epoch.ts new file mode 100644 index 0000000..00b74d7 --- /dev/null +++ b/src/services/gate-epoch.ts @@ -0,0 +1,69 @@ +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/mongodb-store.ts b/src/services/mongodb-store.ts index cff692e..4762d9c 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,5 +1,9 @@ import { DocumentUpdate, DocumentCommit } from "../types/index"; -import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel } from "../database/models"; +import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel, DocumentEditEpochModel } from "../database/models"; + +export class SessionTerminatedError extends Error { + constructor() { super("session terminated"); this.name = "SessionTerminatedError"; } +} export class MongoDBStore { // Update management @@ -9,7 +13,10 @@ export class MongoDBStore { documentId: update.documentId, sessionDid: update.sessionDid, }); - if (!session || session.state === "terminated") { + if (session?.state === "terminated") { + throw new SessionTerminatedError(); + } + if (!session) { return update; // ephemeral relay — no durable row, no seq burned } @@ -36,7 +43,9 @@ export class MongoDBStore { await mongoUpdate.save(); return { ...update, seq }; } catch (error) { - console.error("Error creating update:", error); + if (!(error instanceof SessionTerminatedError)) { + console.error("Error creating update:", error); + } throw error; } } @@ -161,6 +170,34 @@ export class MongoDBStore { return { ...snapshot, seq, updateType: "snapshot" }; } + /** + * View-plane keep-latest: one row per (documentId, fileKeyEpoch), overwritten in place. + * fileKeyEpoch is client-asserted, so it never drives a destructive prune — recency comes + * from the server-stamped createdAt at read time. + */ + async upsertMirrorSnapshot(mirror: { + documentId: string; + data: string; + fileKeyEpoch: number; + sessionDid: string; + createdAt: number; + }): Promise { + await DocumentMirrorModel.findOneAndUpdate( + { documentId: mirror.documentId, fileKeyEpoch: mirror.fileKeyEpoch }, + { $set: { data: mirror.data, createdAt: mirror.createdAt, authorSessionDid: mirror.sessionDid } }, + { upsert: true } + ); + } + + /** The most recently written mirror snapshot (server-stamped createdAt), or null. Open read. */ + async getLatestMirror( + documentId: string + ): Promise<{ data: string; fileKeyEpoch: number; createdAt: number } | null> { + const row: any = await DocumentMirrorModel.findOne({ documentId }).sort({ createdAt: -1 }).lean(); + if (!row) return null; + return { data: row.data, fileKeyEpoch: row.fileKeyEpoch, createdAt: row.createdAt }; + } + async getHydrationRange( documentId: string, sessionDid: string, @@ -214,11 +251,13 @@ export class MongoDBStore { await DocumentMetaModel.findOneAndUpdate( { _id: meta.documentId }, { - $set: { - sessionDid: meta.sessionDid, + $setOnInsert: { ownerDid: meta.ownerDid, ownerIdentityDid: meta.ownerIdentityDid, portalAddress: meta.portalAddress, + }, + $set: { + sessionDid: meta.sessionDid, editLock: meta.editLock, title: meta.title, updatedAt: Date.now(), @@ -228,6 +267,66 @@ export class MongoDBStore { ); } + // First-writer-immutable document->owner binding (see docs/architecture/edit-permission.md). + // The pin closes C1: a colliding appFileId minted on another portal can never rebind an + // already-pinned documentId. Returns the EFFECTIVE portalAddress so the caller can reject + // a mismatch atomically (no read-modify-write race). Two steps so a LEGACY row that predates + // the pin (portalAddress null/absent) gets backfilled instead of locking its real owner out — + // a filter-guarded upsert would dup-key on an already-pinned _id, so the ensure-then-backfill + // split is deliberate. Mirrors session-manager.fillOwnerIdentityDidIfAbsent. + async pinDocumentPortalIfAbsent(p: { + documentId: string; + portalAddress: string; + ownerDid: string | null; + ownerIdentityDid: string | null; + sessionDid: string; + }): Promise<{ portalAddress: string | null }> { + // 1. Ensure the row exists; pin all binding fields on INSERT only. + await DocumentMetaModel.findOneAndUpdate( + { _id: p.documentId }, + { + $setOnInsert: { + portalAddress: p.portalAddress, + ownerDid: p.ownerDid, + ownerIdentityDid: p.ownerIdentityDid, + sessionDid: p.sessionDid, + updatedAt: Date.now(), + isPublished: false, + }, + }, + { upsert: true, writeConcern: { w: "majority", j: true } } + ); + // 2. Backfill a legacy null/absent portal — never overwrites an existing real pin. + await DocumentMetaModel.updateOne( + { _id: p.documentId, $or: [{ portalAddress: null }, { portalAddress: { $exists: false } }] }, + { $set: { portalAddress: p.portalAddress } } + ); + // 3. Read the effective pin. + const doc: any = await DocumentMetaModel.findById(p.documentId).select("portalAddress").lean(); + return { portalAddress: doc?.portalAddress ?? null }; + } + + async getDocumentMeta( + documentId: string + ): Promise<{ editLock: string | null; title: string | null } | null> { + const meta: any = await DocumentMetaModel.findById(documentId).select("editLock title").lean(); + if (!meta) return null; + return { editLock: meta.editLock ?? null, title: meta.title ?? null }; + } + + async setMinEditEpoch(documentId: string, epoch: number): Promise { + await DocumentEditEpochModel.findOneAndUpdate( + { _id: documentId }, + { $max: { minEditEpoch: epoch } }, + { upsert: true } + ); + } + + async getMinEditEpoch(documentId: string): Promise { + const row: any = await DocumentEditEpochModel.findById(documentId).lean(); + return row?.minEditEpoch ?? 0; + } + // Discovery: docs bound to the proven owner (identity DID or portal owner DID) — recovery for a wiped device. async listDocumentsForOwner( by: { ownerIdentityDid?: string; ownerDid?: string } @@ -271,6 +370,21 @@ export class MongoDBStore { ); } + /** Open existence probe: lets the client distinguish "created but not yet + * published" (meta row exists from the owner's session) from a real 404. */ + async getShareContext( + documentId: string + ): Promise<{ exists: boolean; isPublished: boolean }> { + const meta: any = await DocumentMetaModel.findById(documentId) + .select("isPublished") + .lean(); + if (!meta) return { exists: false, isPublished: false }; + return { + exists: true, + isPublished: meta.isPublished === true, + }; + } + // Commit management async createCommit(commit: DocumentCommit): Promise { try { @@ -411,9 +525,40 @@ export class MongoDBStore { DocumentMetaModel.deleteOne({ _id: documentId }), SessionModel.deleteMany({ documentId }), CounterModel.deleteOne({ _id: documentId }), + DocumentMirrorModel.deleteMany({ documentId }), + DocumentEditEpochModel.deleteOne({ _id: documentId }), ]); } + // Reversible tombstone driven by the on-chain DeletedFile webhook (see + // docs/architecture/edit-permission.md). Idempotent: a second call against an + // already-tombstoned doc still returns true instead of erroring. + async tombstoneDocument(documentId: string, reason: string): Promise { + const res = await DocumentMetaModel.updateOne( + { _id: documentId, tombstonedAt: null }, + { $set: { tombstonedAt: Date.now(), tombstoneReason: reason } } + ); + if (res.matchedCount > 0) return true; + const existing: any = await DocumentMetaModel.findById(documentId).select("tombstonedAt").lean(); + return !!existing?.tombstonedAt; + } + + async isTombstoned(documentId: string): Promise { + const m: any = await DocumentMetaModel.findById(documentId).select("tombstonedAt").lean(); + return !!m?.tombstonedAt; + } + + // Grace-window irreversible purge. Reuses the existing collection deletes. + async purgeTombstonedBefore(cutoffMs: number, batchSize: number): Promise { + const rows: any[] = await DocumentMetaModel.find({ tombstonedAt: { $ne: null, $lte: cutoffMs } }) + .select("_id") + .limit(batchSize) + .lean(); + const ids = rows.map((r) => String(r._id)); + for (const id of ids) await this.purgeDocument(id); + return ids; + } + // Conservative orphan GC: terminated ddoc streams with no editLock and no snapshot row. // A real draft always has an editLock or a snapshot, so it is never swept. async collectOrphans(graceMs: number): Promise { diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts index 6192c19..18348ed 100644 --- a/src/services/owner-op-routes.ts +++ b/src/services/owner-op-routes.ts @@ -3,24 +3,54 @@ 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 } from "viem"; +import { Hex, isAddress } from "viem"; +import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../utils/contract"; + +// Duplicated from socket-handlers.ts (not exported there); keep in sync if that changes. +function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { + if (!address || !isAddress(address)) { + return false; + } + return true; +} export interface OwnerOpDeps { authService: Pick; sessionManager: Pick< SessionManager, - "getSession" | "setCollabJoinEnabled" | "getNonTerminatedSessionsForDocument" + "getSession" | "setCollabJoinEnabled" | "setWorkspaceEditEnabled" | "getNonTerminatedSessionsForDocument" >; } +// Absence-vs-skew evidence for the 404 class: "no sessions at all" means the owner +// socket never established the room; a non-empty list means the client derived a +// sessionDid the server doesn't have (stale/rotated roomKey). +async function logSessionNotFound( + op: string, + documentId: string, + sessionDid: string | undefined, + sessionManager: Pick +): Promise { + try { + const others = await sessionManager.getNonTerminatedSessionsForDocument(documentId); + console.warn( + `[owner-op:${op}] session not found doc=${documentId} did=${sessionDid} nonTerminated=[${others.map((t) => t.sessionDid).join(",")}]` + ); + } catch { + console.warn(`[owner-op:${op}] session not found doc=${documentId} did=${sessionDid}`); + } +} + export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, enabled, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, enabled, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + await logSessionNotFound("collab-join-enabled", documentId, sessionDid, deps.sessionManager); res.status(404).json({ error: "Session not found" }); return; } @@ -29,7 +59,7 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) ddocId: documentId, boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress: identityContractAddress as Hex, + identityToken, ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, }); if (!authorized) { @@ -45,8 +75,9 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); const sessionDids = new Set(targets.map((t) => t.sessionDid)); sessionDids.add(sessionDid); + let updated = 0; for (const sd of sessionDids) { - await deps.sessionManager.setCollabJoinEnabled(documentId, sd, !!enabled); + if (await deps.sessionManager.setCollabJoinEnabled(documentId, sd, !!enabled)) updated++; } if (enabled === false) { @@ -62,7 +93,118 @@ export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) } } - res.status(200).json({ ok: true }); + res.status(200).json({ ok: true, updated }); + }; +} + +export function createWorkspaceEditTierHandler(deps: OwnerOpDeps, io: AppServer) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, enabled, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + await logSessionNotFound("workspace-edit-tier", documentId, sessionDid, deps.sessionManager); + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Doc-wide, mirroring collab-join-enabled: the flag lives on every session so a joiner + // reads it whatever session its roomKey derives. + const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); + const sessionDids = new Set(targets.map((t) => t.sessionDid)); + sessionDids.add(sessionDid); + let updated = 0; + for (const sd of sessionDids) { + if (await deps.sessionManager.setWorkspaceEditEnabled(documentId, sd, !!enabled)) updated++; + } + + if (enabled === false) { + // Synchronous revocation: drop live workspace-rail sockets only — public/GP editors + // are governed by their own rails. + for (const sd of sessionDids) { + const roomName = getRoomName(documentId, sd); + const sockets = await io.in(roomName).fetchSockets(); + for (const s of sockets) { + if (s.data.rail === "workspace") s.disconnect(true); + } + } + } + + res.status(200).json({ ok: true, updated }); + }; +} + +/** Targeted eviction of specific gp-actor handles: bust the per-actor edit-bound cache, + * then drop the matching live sockets across every session room of the doc. Co-editors + * with other handles are untouched. */ +export function createEvictEditActorsHandler( + deps: OwnerOpDeps & { editBoundCache: EditBoundCache; mongodbStore: Pick }, + io: AppServer +) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, handles, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + await logSessionNotFound("evict-edit-actors", documentId, sessionDid, deps.sessionManager); + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Stamp the floor even when rotation is deferred, so a stale-UCAN rejoin is blocked + // immediately rather than waiting on the next epoch bump to propagate. gateEpoch is + // optional here — an invalid value is silently skipped rather than failing the evict. + if (Number.isInteger(req.body?.gateEpoch) && req.body.gateEpoch >= 0) { + await deps.mongodbStore.setMinEditEpoch(documentId, req.body.gateEpoch); + } + + const list = Array.isArray(handles) ? handles.filter((h) => typeof h === "string") : []; + deps.editBoundCache.evict(documentId, list); + + let dropped = 0; + if (list.length > 0) { + const handleSet = new Set(list); + const targets = await deps.sessionManager.getNonTerminatedSessionsForDocument(documentId); + const sessionDids = new Set(targets.map((t) => t.sessionDid)); + sessionDids.add(sessionDid); + for (const sd of sessionDids) { + const sockets = await io.in(getRoomName(documentId, sd)).fetchSockets(); + for (const s of sockets) { + if (s.data.railKind === "gp-actor" && s.data.actorHandle && handleSet.has(s.data.actorHandle)) { + s.disconnect(true); + dropped++; + } + } + } + } + + res.status(200).json({ ok: true, evicted: list.length, dropped }); }; } @@ -73,11 +215,11 @@ export interface ListMyDocumentsDeps { export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) { return async (req: Request, res: Response): Promise => { - const { identityToken, identityContractAddress, portalAddress } = req.body || {}; + const { identityToken, portalAddress } = req.body || {}; // Path 1 — identity: the capability binds the portalAddress (a discovery scope, not a single ddocId). - if (identityToken && identityContractAddress && portalAddress) { - const signingDid = await deps.authService.verifyIdentityToken(identityToken, identityContractAddress, portalAddress); + if (identityToken && portalAddress) { + const signingDid = await deps.authService.verifyIdentityToken(identityToken, portalAddress); if (signingDid) { const documents = await deps.mongodbStore.listDocumentsForOwner({ ownerIdentityDid: signingDid }); res.status(200).json({ documents }); @@ -97,14 +239,15 @@ export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) { export function createDeleteDocumentHandler(deps: { authService: Pick; sessionManager: Pick; - mongodbStore: Pick; + mongodbStore: Pick; }) { return async (req: Request, res: Response): Promise => { const documentId = req.params.documentId; - const { sessionDid, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + const { sessionDid, identityToken, ownerToken, ownerAddress, portalAddress } = req.body || {}; const session = await deps.sessionManager.getSession(documentId, sessionDid); if (!session) { + console.warn(`[owner-op:delete] session not found doc=${documentId} did=${sessionDid}`); res.status(404).json({ error: "Session not found" }); return; } @@ -119,14 +262,125 @@ export function createDeleteDocumentHandler(deps: { ddocId: documentId, boundOwnerIdentityDid: session.ownerIdentityDid ?? null, boundOwnerDid: session.ownerDid ?? null, - identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress, + identityToken, ownerToken, ownerAddress, portalAddress, }); if (!authorized) { res.status(403).json({ error: "Not the document owner" }); return; } - await deps.mongodbStore.purgeDocument(documentId); + // Reversible tombstone, not a purge — see docs/architecture/edit-permission.md. + // Irreversible purge is driven only by the on-chain webhook + grace job. + await deps.mongodbStore.tombstoneDocument(documentId, "owner-delete"); res.status(200).json({ ok: true }); }; } + +export function createMirrorReadHandler(deps: { mongodbStore: Pick }) { + return async (req: Request, res: Response): Promise => { + // Open read: the payload is fileKey-ciphertext, useless without the key (public → linkKey; + // private → gate/appLock). Access control is the key, not this endpoint. + const documentId = req.params.documentId; + // Tombstoned docs serve 404 — see docs/architecture/edit-permission.md. + if (await deps.mongodbStore.isTombstoned(documentId)) { + res.status(404).json({ error: "Not found" }); + return; + } + const mirror = await deps.mongodbStore.getLatestMirror(documentId); + if (!mirror) { + res.status(404).json({ error: "No mirror snapshot" }); + return; + } + res.status(200).json(mirror); + }; +} + +/** Open read: existence only (same trust model as the mirror GET). */ +export function createShareContextHandler(deps: { + mongodbStore: Pick; + sessionManager?: Pick; +}) { + return async (req: Request, res: Response): Promise => { + // Express 4 doesn't catch async rejections — an unhandled store error + // would hang the request (and the client has no timeout-free fallback). + try { + const ctx = await deps.mongodbStore.getShareContext(req.params.documentId); + // Optional probe: same lookup the owner-ops open with, so `sessionExists` + // answers exactly "will an owner-op on this sessionDid find the session". + const sessionDid = req.query?.sessionDid; + if (typeof sessionDid === "string" && sessionDid && deps.sessionManager) { + const session = await deps.sessionManager.getSession(req.params.documentId, sessionDid); + res.status(200).json({ ...ctx, sessionExists: !!session }); + return; + } + res.status(200).json(ctx); + } catch { + res.status(500).json({ error: "share-context lookup failed" }); + } + }; +} + +export function createEvictWorkspaceMemberHandler( + deps: { + authService: Pick; + sessionManager: Pick; + }, + io: AppServer +) { + return async (req: Request, res: Response): Promise => { + const portalAddress = req.params.portalAddress as Hex; + const { memberIdentityDid, ownerToken, ownerAddress } = req.body || {}; + + if ( + !validateHexAddress(portalAddress, "portalAddress") || + !validateHexAddress(ownerAddress, "ownerAddress") + ) { + res.status(400).json({ error: "Invalid portal or owner address" }); + return; + } + if (typeof memberIdentityDid !== "string" || !memberIdentityDid.startsWith("did:")) { + res.status(400).json({ error: "memberIdentityDid must be a DID string" }); + return; + } + if (typeof ownerToken !== "string" || !ownerToken) { + res.status(400).json({ error: "ownerToken is required" }); + return; + } + + // Owner-only: the presented address must BE the portal creator + // (Portal.owner() = the workspace Admin Sub-Agent). collaboratorKeys(MSA) + // is the shared member DID every member can sign for — it lives at a + // different address and never passes this gate. + const portalOwner = await getPortalOwnerAddress(portalAddress); + if (!portalOwner || portalOwner.toLowerCase() !== String(ownerAddress).toLowerCase()) { + res.status(403).json({ error: "Not the portal owner" }); + return; + } + const ownerDid = await deps.authService.verifyOwnerToken( + ownerToken, + portalAddress, + ownerAddress as Hex + ); + if (!ownerDid) { + res.status(403).json({ error: "Owner token verification failed" }); + return; + } + + bustOwnerDidCacheForPortal(portalAddress); + + const sessions = await deps.sessionManager.getNonTerminatedSessionsForPortal(portalAddress); + let dropped = 0; + for (const s of sessions) { + const roomName = getRoomName(s.documentId, s.sessionDid); + const sockets = await io.in(roomName).fetchSockets(); + for (const sock of sockets) { + if (sock.data.actorIdentityDid === memberIdentityDid) { + sock.disconnect(true); + dropped++; + } + } + } + + res.status(200).json({ ok: true, sessions: sessions.length, dropped }); + }; +} diff --git a/src/services/rotate-route.ts b/src/services/rotate-route.ts new file mode 100644 index 0000000..5c73cb0 --- /dev/null +++ b/src/services/rotate-route.ts @@ -0,0 +1,106 @@ +import type { Request, Response } from "express"; +import type { AppServer } from "../types/index"; +import type { AuthService } from "./auth"; +import type { SessionManager } from "./session-manager"; +import type { MongoDBStore } from "./mongodb-store"; +import type { RotationCoordinator } from "./rotation-coordinator"; +import { getRoomName } from "./socket-handlers"; +import { Hex } from "viem"; + +const T_DRAIN_MS = 10_000; + +export interface RotateDeps { + authService: Pick; + sessionManager: Pick; + mongodbStore: Pick; + rotationCoordinator: Pick; + terminateOldSession: (documentId: string, sessionDid: string, appType: "ddoc" | "dsheet") => Promise; +} + +export function createRotateSessionHandler(deps: RotateDeps, io: AppServer) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { + oldSessionDid, newSessionDid, payload, gateEpoch, + identityToken, ownerToken, ownerAddress, portalAddress, + } = req.body || {}; + + if ( + typeof oldSessionDid !== "string" || + typeof newSessionDid !== "string" || + typeof payload !== "string" || + !Number.isInteger(gateEpoch) || + gateEpoch < 0 + ) { + res.status(400).json({ error: "oldSessionDid, newSessionDid, payload and a non-negative integer gateEpoch are required" }); + return; + } + + // One rotation per doc at a time; the gate's epoch CAS is the cross-device backstop. + // See docs/architecture/gp-semaphore.md. + if (deps.rotationCoordinator.isActive(documentId)) { + res.status(409).json({ error: "Rotation already in progress" }); + return; + } + + const oldSession = await deps.sessionManager.getSession(documentId, oldSessionDid); + if (!oldSession) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (oldSession as any).ownerIdentityDid ?? null, + boundOwnerDid: oldSession.ownerDid ?? null, + identityToken, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + // Create the post-rotation session WITHOUT the owner-/auth takeover sweep, so the old + // session stays live through the make-before-break cutover. Idempotent on retry. + if (!(await deps.sessionManager.getSession(documentId, newSessionDid))) { + await deps.sessionManager.createSession({ + documentId, + sessionDid: newSessionDid, + ownerDid: oldSession.ownerDid, + ownerIdentityDid: (oldSession as any).ownerIdentityDid ?? null, + portalAddress: oldSession.portalAddress ?? undefined, + collabJoinEnabled: (oldSession as any).collabJoinEnabled ?? false, + roomInfo: oldSession.roomInfo, + appType: oldSession.appType ?? "ddoc", + } as any); + } + + await deps.mongodbStore.setMinEditEpoch(documentId, gateEpoch); + + const oldRoom = getRoomName(documentId, oldSessionDid); + io.to(oldRoom).emit("/session/epoch_available", { roomId: documentId, epoch: gateEpoch, payload }); + + // Admitted editors always have a rail set at JOIN; a role-capped read-only socket + // (e.g. the owner's headless joinOnly read) is not, and must not stall the barrier. + const expected = (await io.in(oldRoom).fetchSockets()) + .filter((s) => s.data.role !== "owner" && !!s.data.rail) + .map((s) => s.id); + + deps.rotationCoordinator.begin({ + documentId, + oldSessionDid, + epoch: gateEpoch, + expected, + onCutover: () => { + io.to(oldRoom).emit("/session/cutover", { roomId: documentId, epoch: gateEpoch }); + setTimeout(() => { + void deps.terminateOldSession(documentId, oldSessionDid, oldSession.appType ?? "ddoc") + .catch((e) => console.error("rotate terminate error:", e)); + }, T_DRAIN_MS); + }, + }); + + res.status(200).json({ ok: true, newSessionDid, liveEditors: expected.length }); + }; +} diff --git a/src/services/rotation-coordinator.ts b/src/services/rotation-coordinator.ts new file mode 100644 index 0000000..9882adc --- /dev/null +++ b/src/services/rotation-coordinator.ts @@ -0,0 +1,66 @@ +interface Barrier { + epoch: number; + expected: Set; + acked: Set; + timer: ReturnType | null; + fired: boolean; + onCutover: () => void; +} + +// Best-effort ack barrier for a seamless cutover. Single-instance accurate; under the Redis +// adapter an ack may land on another instance, so correctness rests on the timeout + the +// client decrypt-miss self-heal, never on ack completeness. See docs/architecture/gp-semaphore.md. +export class RotationCoordinator { + private barriers = new Map(); + constructor(private tBarrierMs: number = 4000) {} + + begin(args: { + documentId: string; + oldSessionDid: string; + epoch: number; + expected: string[]; + onCutover: () => void; + }): void { + this.finish(args.documentId); // supersede any prior barrier for this doc + const b: Barrier = { + epoch: args.epoch, + expected: new Set(args.expected), + acked: new Set(), + timer: null, + fired: false, + onCutover: args.onCutover, + }; + this.barriers.set(args.documentId, b); + if (b.expected.size === 0) return this.fire(args.documentId, b); + b.timer = setTimeout(() => this.fire(args.documentId, b), this.tBarrierMs); + } + + recordAck(documentId: string, socketId: string): void { + const b = this.barriers.get(documentId); + if (!b || b.fired) return; + if (!b.expected.has(socketId)) return; + b.acked.add(socketId); + if (b.acked.size >= b.expected.size) this.fire(documentId, b); + } + + isActive(documentId: string): boolean { + const b = this.barriers.get(documentId); + return !!b && !b.fired; + } + + finish(documentId: string): void { + const b = this.barriers.get(documentId); + if (b?.timer) clearTimeout(b.timer); + this.barriers.delete(documentId); + } + + private fire(documentId: string, b: Barrier): void { + if (b.fired) return; + b.fired = true; + if (b.timer) clearTimeout(b.timer); + b.onCutover(); + this.barriers.delete(documentId); + } +} + +export const rotationCoordinator = new RotationCoordinator(); diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 47adc80..a755940 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -10,6 +10,7 @@ interface RuntimeSession { ownerIdentityDid?: string; portalAddress?: string; collabJoinEnabled?: boolean; + workspaceEditEnabled?: boolean; } export class SessionManager { @@ -106,6 +107,23 @@ export class SessionManager { } } + // Rotation heal: the chain re-registered the portal collab DID (member + // removal); adopt it so compares against the stored value keep working. + async updateSessionOwnerDid( + documentId: string, + sessionDid: string, + ownerDid: string + ): Promise { + try { + await SessionModel.updateOne({ documentId, sessionDid }, { $set: { ownerDid } }); + const sessionKey = this.getSessionKey(documentId, sessionDid); + const mem = this.inMemorySessions.get(sessionKey); + if (mem) mem.ownerDid = ownerDid; + } catch (error) { + console.error("Error updating session ownerDid:", error); + } + } + async getSession(documentId: string, sessionDid: string): Promise { // Check in-memory first for active sessions const sessionKey = this.getSessionKey(documentId, sessionDid); @@ -132,6 +150,7 @@ export class SessionManager { ownerIdentityDid: dbSession.ownerIdentityDid ?? undefined, portalAddress: dbSession.portalAddress ?? undefined, collabJoinEnabled: dbSession.collabJoinEnabled, + workspaceEditEnabled: dbSession.workspaceEditEnabled, }; // Store in memory @@ -140,19 +159,71 @@ export class SessionManager { return runtimeSession; } + // Terminated-inclusive lookup: same as getSession minus the state filter, for the + // joinOnly headless read path — termination blocks writes only (state-based, enforced + // independently in createUpdate), so a rotated-away session's durable rows must still + // be readable. Never memoized into inMemorySessions: doing so would let a later plain + // getSession() resurrect a terminated session for non-joinOnly admission. + async getSessionIncludingTerminated( + documentId: string, + sessionDid: string + ): Promise { + const sessionKey = this.getSessionKey(documentId, sessionDid); + const inMemorySession = this.inMemorySessions.get(sessionKey); + if (inMemorySession) { + return inMemorySession; + } + + const dbSession = await SessionModel.findOne({ documentId, sessionDid }); + if (!dbSession) return undefined; + + return { + documentId: dbSession.documentId, + sessionDid: dbSession.sessionDid, + ownerDid: dbSession.ownerDid, + clients: new Set(), + roomInfo: dbSession.roomInfo, + appType: dbSession.appType, + ownerIdentityDid: dbSession.ownerIdentityDid ?? undefined, + portalAddress: dbSession.portalAddress ?? undefined, + collabJoinEnabled: dbSession.collabJoinEnabled, + workspaceEditEnabled: dbSession.workspaceEditEnabled, + }; + } + async getCollabJoinEnabled(documentId: string, sessionDid: string): Promise { const doc = await SessionModel.findOne({ documentId, sessionDid }, { collabJoinEnabled: 1 }).lean(); return (doc as any)?.collabJoinEnabled; } - async setCollabJoinEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + async setCollabJoinEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { const sessionKey = this.getSessionKey(documentId, sessionDid); const session = this.inMemorySessions.get(sessionKey); if (session) session.collabJoinEnabled = enabled; try { - await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { collabJoinEnabled: enabled }); + const res = await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { collabJoinEnabled: enabled }); + return res !== null || session !== undefined; } catch (error) { console.error("Error setting collabJoinEnabled:", error); + return false; + } + } + + async getWorkspaceEditEnabled(documentId: string, sessionDid: string): Promise { + const doc = await SessionModel.findOne({ documentId, sessionDid }, { workspaceEditEnabled: 1 }).lean(); + return (doc as any)?.workspaceEditEnabled; + } + + async setWorkspaceEditEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + const sessionKey = this.getSessionKey(documentId, sessionDid); + const session = this.inMemorySessions.get(sessionKey); + if (session) session.workspaceEditEnabled = enabled; + try { + const res = await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { workspaceEditEnabled: enabled }); + return res !== null || session !== undefined; + } catch (error) { + console.error("Error setting workspaceEditEnabled:", error); + return false; } } @@ -247,10 +318,17 @@ export class SessionManager { async getOtherNonTerminatedSessions( documentId: string, ownerDid: string, + portalAddress: string | null, excludeSessionDid?: string ): Promise> { try { - const query: Record = { documentId, ownerDid, state: { $ne: "terminated" } }; + const or: Record[] = [{ ownerDid }]; + if (portalAddress && /^0x[0-9a-fA-F]{40}$/.test(portalAddress)) { + // Post-rotation sessions store the old ownerDid — the portal match is + // what lets a new-DID owner sweep them. + or.push({ portalAddress: { $regex: `^${portalAddress}$`, $options: "i" } }); + } + const query: Record = { documentId, state: { $ne: "terminated" }, $or: or }; if (excludeSessionDid) { query.sessionDid = { $ne: excludeSessionDid }; } @@ -271,19 +349,41 @@ export class SessionManager { // rooms — a stale client-supplied sessionDid must not leave the current room open. async getNonTerminatedSessionsForDocument( documentId: string - ): Promise> { + ): Promise> { try { const sessions = await SessionModel.find( { documentId, state: { $ne: "terminated" } }, - { sessionDid: 1 } + { sessionDid: 1, appType: 1 } ).lean(); - return sessions.map((s: any) => ({ sessionDid: s.sessionDid })); + return sessions.map((s: any) => ({ sessionDid: s.sessionDid, appType: s.appType })); } catch (error) { console.error("Error getting non-terminated sessions for document:", error); return []; } } + async getNonTerminatedSessionsForPortal( + portalAddress: string + ): Promise> { + if (!/^0x[0-9a-fA-F]{40}$/.test(portalAddress)) return []; + try { + const sessions = await SessionModel.find( + { + portalAddress: { $regex: `^${portalAddress}$`, $options: "i" }, + state: { $ne: "terminated" }, + }, + { documentId: 1, sessionDid: 1 } + ).lean(); + return sessions.map((s: any) => ({ + documentId: s.documentId, + sessionDid: s.sessionDid, + })); + } catch (error) { + console.error("Error getting non-terminated sessions for portal:", error); + return []; + } + } + async getActiveSessionsCount(): Promise { const inMemoryCount = this.inMemorySessions.size; if (inMemoryCount > 0) { diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts index 5a09831..31047b1 100644 --- a/src/services/socket-handlers.deps.ts +++ b/src/services/socket-handlers.deps.ts @@ -1,9 +1,11 @@ import type { AuthService } from "./auth"; import type { SessionManager } from "./session-manager"; import type { MongoDBStore } from "./mongodb-store"; +import type { 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 3ff7e4e..f5d1d0a 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -11,10 +11,12 @@ import { UpdateHistoryArgs, UpdateHistoryResponseData, SnapshotArgs, + MirrorSnapshotArgs, DocumentMetaArgs, PeersListArgs, AwarenessArgs, TerminateSessionArgs, + EpochLoadedArgs, DocumentCommit, AppServer, AppSocket, @@ -23,15 +25,19 @@ import { } from "../types/index"; import { requireAuth } from "./auth-middleware"; import { authService } from "./auth"; -import { mongodbStore } from "./mongodb-store"; +import { mongodbStore, SessionTerminatedError } from "./mongodb-store"; import { sessionManager } from "./session-manager"; +import { editBoundCache } from "./gate-epoch"; +import { rotationCoordinator } from "./rotation-coordinator"; import { Hex, isAddress } from "viem"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; +import { config } from "../config"; const defaultDeps: SocketHandlerDeps = { authService, sessionManager, mongodbStore, + editBoundCache, }; function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { @@ -45,6 +51,10 @@ export function getRoomName(documentId: string, sessionDid: string): string { return `session::${documentId}__${sessionDid}`; } +// Awareness-path re-check throttle; matches the edit-bound TTL so a revoked-but-idle +// actor is caught within one cache window without adding gate traffic. +const ADMIT_RECHECK_INTERVAL_MS = 10_000; + /** * Coerce an untrusted appType into a known AppType. Anything that is not exactly * "dsheet" becomes "ddoc" (the legacy default), so missing or invalid values @@ -81,14 +91,18 @@ export function registerEventHandlers(io: AppServer): void { socket.on("/documents/snapshot", (args, callback) => handleSnapshot(defaultDeps, socket, args, callback) ); + socket.on("/documents/mirror-snapshot", (args, callback) => + handleMirrorSnapshot(defaultDeps, socket, args, callback) + ); socket.on("/documents/meta", (args, callback) => handleSetDocumentMeta(defaultDeps, socket, args, callback) ); socket.on("/documents/peers/list", (args, callback) => handlePeersList(io, socket, args, callback)); - socket.on("/documents/awareness", (args) => handleAwareness(io, socket, args)); + socket.on("/documents/awareness", (args) => handleAwareness(defaultDeps, io, socket, args)); socket.on("/documents/terminate", (args, callback) => handleTerminateSession(defaultDeps, io, socket, args, callback) ); + socket.on("/session/epoch_loaded", (args, callback) => handleEpochLoaded(socket, args, callback)); // Disconnection handling socket.on("disconnecting", () => handleDisconnecting(defaultDeps, socket)); @@ -140,12 +154,35 @@ export async function handleAuth( }); } - const existingSession = await sessionManager.getSession(documentId, sessionDid); + let existingSession = await sessionManager.getSession(documentId, sessionDid); let role: "owner" | "editor"; let sessionType: "new" | "existing"; let roomInfo: string | undefined; let resolvedAppType: AppType; + let rail: "gp" | "workspace" | "public" | undefined = undefined; + let railKind: "gp-actor" | "workspace" | "public" | undefined = undefined; + let actorHandle: string | undefined = undefined; + let provenIdentityDid: string | null = null; + + // joinOnly is strictly privilege-reducing: never create or bind a room on behalf of + // a joiner. A workspace member's valid SHARED ownerToken would otherwise create the + // session and bind THEIR identity as the room's root of trust (member-first-bind). + // Distinct code so clients can render read-only-until-creator-opens. + if (!existingSession && args.joinOnly === true) { + // Termination blocks WRITES only (state-based, enforced independently in + // createUpdate) — a rotated-away session's durable rows are still readable, so + // retry terminated-inclusive before giving up. See docs/architecture/gp-semaphore.md. + existingSession = await sessionManager.getSessionIncludingTerminated(documentId, sessionDid); + if (!existingSession) { + return callback({ + status: false, + statusCode: 404, + error: "Room not established", + errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, + }); + } + } if (!existingSession && args.ownerToken) { // - Set up a new session (owner flow) @@ -188,16 +225,11 @@ export async function handleAuth( // UCAN against the on-chain signingDid and bind THAT. Dsheets keep the legacy path // (no durable recovery / owner-op surface) — mirrors the ddoc-only join gate so a // dsheet owner is never rejected here. - let boundOwnerIdentityDid = args.ownerIdentityDid; + let boundOwnerIdentityDid: string | null = null; if (claimedAppType === "ddoc") { - const provenSigningDid = - args.identityToken && args.identityContractAddress - ? await authService.verifyIdentityToken( - args.identityToken, - args.identityContractAddress as Hex, - documentId - ) - : null; + const provenSigningDid = args.identityToken + ? await authService.verifyIdentityToken(args.identityToken, documentId) + : null; if (!provenSigningDid) { return callback({ status: false, @@ -207,12 +239,47 @@ export async function handleAuth( }); } boundOwnerIdentityDid = provenSigningDid; + provenIdentityDid = provenSigningDid; + } + + // C1: pin documentId → portalAddress on the first owner bind, immutably (trust-on-first-use). + // This is bounded by the pre-first-durable-session race window, NOT by documentId secrecy — + // the portable share URL is /document/{id}#k=, so documentId is URL-path-visible (server/ + // link-visible); only the key is in the fragment. The ownerToken proves membership of the + // claimed portal; thereafter no other portal can bind. Residual: a share-link holder can + // pin-hijack a documentId that has never had a durable owner session, permanently locking + // out durable collaboration for it — but content (fileKey-encrypted mirror) and the + // on-chain/IPFS artifact are unaffected, and nothing can be purged. A clean fix + // (pin-at-document-creation / an owner-authenticated reclaim path) is out of scope. The + // doc→portal *deletion* proof lives in the DeletedFile webhook (onlyCollaborator-gated). + // See docs/architecture/edit-permission.md. + if (claimedAppType === "ddoc") { + const { portalAddress: pinnedPortal } = await deps.mongodbStore.pinDocumentPortalIfAbsent({ + documentId, + portalAddress: args.contractAddress as string, + ownerDid, + ownerIdentityDid: boundOwnerIdentityDid, + sessionDid, + }); + + if ( + !pinnedPortal || + pinnedPortal.toLowerCase() !== (args.contractAddress as string).toLowerCase() + ) { + return callback({ + status: false, + statusCode: 403, + error: "Document is owned by a different portal", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + } } // Terminate other sessions with socket notification const otherSessions = await sessionManager.getOtherNonTerminatedSessions( documentId, ownerDid, + args.contractAddress ?? null, sessionDid ); for (const oldSession of otherSessions) { @@ -250,7 +317,7 @@ export async function handleAuth( documentId, sessionDid, ownerDid, - ownerIdentityDid: boundOwnerIdentityDid, + ownerIdentityDid: boundOwnerIdentityDid ?? undefined, portalAddress: args.contractAddress, collabJoinEnabled: false, roomInfo: args.roomInfo, @@ -309,7 +376,69 @@ export async function handleAuth( ); } - role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + // Rotation heal: the chain is the root of trust for the portal collab + // DID; the stored session value is a cache of it. A verified ownerToken + // for the session's OWN portal that resolves to a different DID means + // the workspace rotated (member removal) — adopt it, or the whole team + // stays locked out of the established session. A foreign portal's owner + // fails the address guard; a removed member's stale token never + // verifies against the rotated chain slot. + if ( + ownerDid && + existingSession.portalAddress && + args.contractAddress && + existingSession.portalAddress.toLowerCase() === + args.contractAddress.toLowerCase() && + ownerDid !== existingSession.ownerDid + ) { + await sessionManager.updateSessionOwnerDid( + documentId, + existingSession.sessionDid, + ownerDid + ); + existingSession.ownerDid = ownerDid; + } + + // Identity-based role (ddoc-only): the shared team-portal + // DID cannot tell a member from the creator, but the per-person identity signingDid + // can. On a bound session a presented identityToken DECIDES the role — owner needs + // BOTH the proven identity match and the portal ownerToken match. An invalid token + // is a 401; a token-less join on a bound session is never owner (capped to editor) — + // a bound session already proved identity once, so a modern client always presents it. + const boundIdentityDid = existingSession.ownerIdentityDid ?? null; + if (storedAppType === "ddoc" && boundIdentityDid && args.identityToken) { + const provenDid = await authService.verifyIdentityToken(args.identityToken, documentId); + if (!provenDid) { + return callback({ + status: false, + statusCode: 401, + error: "Invalid identity proof", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + } + role = + provenDid === boundIdentityDid && ownerDid === existingSession.ownerDid + ? "owner" + : "editor"; + provenIdentityDid = provenDid; + } else if ( + storedAppType === "ddoc" && + boundIdentityDid && + !args.identityToken + ) { + role = "editor"; + } else { + role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + } + + // Captured pre-cap: whatever role resolution (above) already decided is "owner" — + // the same credentials would get full owner rights via the plain (non-joinOnly) + // path, so a joinOnly read for them is strictly privilege-reducing, never a new grant. + const ownerVerifiedPreCap = role === "owner"; + + // joinOnly caps the role: on an unbound/legacy session the shared-DID compare + // resolves a team member to owner — a join-only bearer never gets owner powers. + if (args.joinOnly === true && role === "owner") role = "editor"; // R3 heal (ddoc-only): a session bound before identity proof was required (or // bound empty) is filled — once, atomically — by a proven owner, so the pre-fix @@ -317,16 +446,13 @@ export async function handleAuth( if ( storedAppType === "ddoc" && role === "owner" && + args.joinOnly !== true && !existingSession.ownerIdentityDid && - args.identityToken && - args.identityContractAddress + args.identityToken ) { - const provenSigningDid = await authService.verifyIdentityToken( - args.identityToken, - args.identityContractAddress as Hex, - documentId - ); + const provenSigningDid = await authService.verifyIdentityToken(args.identityToken, documentId); if (provenSigningDid) { + provenIdentityDid = provenSigningDid; await sessionManager.fillOwnerIdentityDidIfAbsent( documentId, existingSession.sessionDid, @@ -336,19 +462,79 @@ export async function handleAuth( } } - // collabJoinEnabled gate (R5-b): ddoc-ONLY (dsheet must never break; new dsheet rooms also get - // collabJoinEnabled:false from createSession, so an unscoped gate would kill all dsheet collaboration). - // Read the flag FRESH FROM MONGO (HTTP process flips it in Task 13; in-memory copy can be stale). - // Only an explicit `false` closes a ddoc room to non-owners; undefined (legacy) stays open; owners bypass. - if (storedAppType === "ddoc" && role === "editor") { - const joinEnabled = await sessionManager.getCollabJoinEnabled(documentId, existingSession.sessionDid); - if (joinEnabled === false) { - return callback({ - status: false, - statusCode: 403, - error: "Link sharing is disabled for this document", - errorCode: ErrorCode.JOIN_DISABLED, - }); + // Rail-exclusive edit admission (ddoc-only, non-owner): resolved by the credential the + // client presents, never a sequential try-each. An editUcan join is GP-or-reject and must + // NEVER fall through to workspace/public — a demoted GP editor still holds the un-rotated + // roomKey, and falling through would let them re-enter as a lower-trust bearer. + // + // Owner-verified joinOnly bypasses this gate entirely (admission only — role stays + // capped to "editor" above): the gate exists to admit non-owner editors onto a grant + // rail, but an owner-shaped bearer already cleared a stricter bar. rail/railKind are + // left unset, so isStillAdmitted's default (checks collabJoinEnabled) fails closed for + // any later write attempt on a private doc — the headless read path never calls it. + if (storedAppType === "ddoc" && role === "editor" && !(args.joinOnly === true && ownerVerifiedPreCap)) { + if (args.editUcan) { + const gp = await authService.verifyEditUcan(args.editUcan, documentId); + if (gp?.kind === "actor") { + const minEpoch = await deps.mongodbStore.getMinEditEpoch(documentId); + if (gp.epoch < minEpoch) { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + // Per-actor positive-state admission (keyed on editHandle = hash(C, docId)); fail-closed on + // cold/unbound. See docs/architecture/gp-semaphore.md. + const state = await deps.editBoundCache.check(documentId, gp.editHandle); + if (state === "bound" || state === "stale-bound") { + rail = "gp"; + railKind = "gp-actor"; + actorHandle = gp.editHandle; + } else { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + } else { + return callback({ + status: false, + statusCode: 403, + error: "Edit access is not authorized for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + } else if ( + // Whole-team PrivateEdit: membership proof is the SHARED portal secret — the + // ownerToken must resolve to the session's own ownerDid (identity already told + // us this bearer is not the creator). A valid-but-DIFFERENT ownerToken is some + // other portal's owner and gets no workspace admission. + ownerDid && + ownerDid === existingSession.ownerDid && + (await sessionManager.getWorkspaceEditEnabled(documentId, existingSession.sessionDid)) === true + ) { + rail = "workspace"; + railKind = "workspace"; + actorHandle = args.ownerAddress?.toLowerCase(); + } else { + // Read fresh from Mongo (an HTTP process can flip this; the in-memory copy may be + // stale). Only an explicit `true` opens a ddoc room to a non-GP, non-workspace bearer. + const joinEnabled = await sessionManager.getCollabJoinEnabled(documentId, existingSession.sessionDid); + if (joinEnabled !== true) { + return callback({ + status: false, + statusCode: 403, + error: "Link sharing is disabled for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + rail = "public"; + railKind = "public"; + actorHandle = args.actorHandle; } } @@ -373,15 +559,32 @@ export async function handleAuth( }); } + // In-place re-auth on rotation cutover: capture the pre-rotation room membership + // BEFORE it's overwritten below, so the socket can be silently migrated off it. + const priorSessionDid = socket.data.sessionDid; + // Set socket data socket.data.authenticated = true; socket.data.documentId = documentId; socket.data.sessionDid = sessionDid; socket.data.role = role; socket.data.appType = resolvedAppType; + socket.data.rail = rail; + socket.data.railKind = railKind; + socket.data.actorHandle = actorHandle; + socket.data.actorIdentityDid = provenIdentityDid ?? undefined; // Join the Socket.IO room const roomName = getRoomName(documentId, sessionDid); + + if (args.rotationCutover && priorSessionDid && priorSessionDid !== sessionDid) { + // Silent migration — no user_left. The socket never disconnects, so the + // `disconnecting` sweep never fires for the pre-rotation sessionDid; do that + // cleanup here instead to keep the old session's client list honest. + socket.leave(getRoomName(documentId, priorSessionDid)); + await sessionManager.removeClientFromSession(documentId, priorSessionDid, socket.id); + } + socket.join(roomName); // Track in session manager (for session lifecycle / deactivation logic) @@ -389,13 +592,22 @@ export async function handleAuth( console.log(sessionType === "new" ? "SETUP DONE" : "JOINED SESSION", documentId, role); - // Broadcast membership change to others in the room - const membershipPayload = { - action: "user_joined" as const, - user: { role }, - roomId: documentId, - }; - socket.to(roomName).emit("/room/membership_change", membershipPayload); + // Broadcast membership change to others in the room — suppressed on a rotation + // cutover re-auth so the surviving socket doesn't blip presence for itself. + if (!args.rotationCutover) { + const membershipPayload = { + action: "user_joined" as const, + user: { role }, + roomId: documentId, + }; + socket.to(roomName).emit("/room/membership_change", membershipPayload); + } + + // Latest stored title beats the session-frozen roomInfo blob for joiners + // arriving after a mid-session rename. + const documentMeta = await deps.mongodbStore + .getDocumentMeta(documentId) + .catch(() => null); callback({ status: true, @@ -405,6 +617,7 @@ export async function handleAuth( role, sessionType, roomInfo, + title: documentMeta?.title ?? null, }, }); } catch (error) { @@ -418,6 +631,20 @@ export async function handleAuth( } } +// Live per-actor edit-admission re-check against the ADMITTED context stamped at JOIN. +// Fail-closed for the actor rail (cold/unbound → reject). See docs/architecture/gp-semaphore.md. +export async function isStillAdmitted(socket: AppSocket, deps: SocketHandlerDeps): Promise { + const { rail, railKind, documentId, sessionDid, actorHandle } = 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 (rail === "workspace") return (await deps.sessionManager.getWorkspaceEditEnabled(documentId, sessionDid)) === true; + return (await deps.sessionManager.getCollabJoinEnabled(documentId, sessionDid)) === true; +} + export async function handleDocumentUpdate( deps: SocketHandlerDeps, io: AppServer, @@ -475,22 +702,52 @@ export async function handleDocumentUpdate( }); } + if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + const revoked = !(await isStillAdmitted(socket, deps)); + if (revoked) { + // Ack the reason first, then drop the socket — the revoked actor must not + // keep presence/read on a session they can no longer write to. + callback({ + status: false, + statusCode: 403, + error: "Edit access has been revoked", + errorCode: ErrorCode.EDIT_REVOKED, + }); + socket.disconnect(true); + return; + } + } + const updateId = uuidv4(); const createdAt = Date.now(); // Persist before broadcasting: a peer must never hold an update that is absent from // the durable seq stream. On write failure this fails safe — nothing is fanned out. - const update = await mongodbStore.createUpdate({ - id: updateId, - documentId, - data, - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt, - sessionDid, - appType: socket.data.appType, - }); + let update; + try { + update = await mongodbStore.createUpdate({ + id: updateId, + documentId, + data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt, + sessionDid, + appType: socket.data.appType, + }); + } catch (err) { + if (err instanceof SessionTerminatedError) { + callback({ + status: false, + statusCode: 409, + error: "Session terminated", + errorCode: ErrorCode.SESSION_TERMINATED, + }); + return; + } + throw err; + } const roomName = getRoomName(documentId, socket.data.sessionDid); socket.to(roomName).emit("/document/content_update", { @@ -823,6 +1080,60 @@ export async function handleSnapshot( } } +// fileKeyEpoch is a client-asserted rotation counter (0 until a private narrow rotates the +// fileKey). The mirror read returns the highest epoch, so an out-of-range value would shadow +// every legitimate write; bound it generously here, at the trust boundary. +const MAX_FILE_KEY_EPOCH = 1_000_000; + +export async function handleMirrorSnapshot( + deps: SocketHandlerDeps, + socket: AppSocket, + args: MirrorSnapshotArgs, + callback: (response: AckResponse<{ ok: true }>) => void +): Promise { + try { + const { sessionManager, mongodbStore } = deps; + if (!requireAuth(socket)) { + return callback({ status: false, statusCode: 401, error: "Not authenticated", errorCode: ErrorCode.NOT_AUTHENTICATED }); + } + // Any admitted editor may author the mirror: the owner, or a rail-admitted non-owner + // editor (socket.data.rail set at JOIN). Viewers never socket-connect (load-on-open). + if (socket.data.role !== "owner" && !socket.data.rail) { + return callback({ status: false, statusCode: 403, error: "Not an editor", errorCode: ErrorCode.COMMIT_UNAUTHORIZED }); + } + // Live per-actor re-check: a demoted editor holding a stale socket must not author a mirror. + if (socket.data.role !== "owner" && !(await isStillAdmitted(socket, deps))) { + callback({ status: false, statusCode: 403, error: "Edit access has been revoked", errorCode: ErrorCode.EDIT_REVOKED }); + socket.disconnect(true); + return; + } + + const { data, fileKeyEpoch } = args; + const documentId = args.documentId || socket.data.documentId; + if (!data || !Number.isSafeInteger(fileKeyEpoch) || fileKeyEpoch < 0 || fileKeyEpoch > MAX_FILE_KEY_EPOCH) { + return callback({ status: false, statusCode: 400, error: "Mirror data and an in-range non-negative integer fileKeyEpoch are required", errorCode: ErrorCode.UPDATE_DATA_MISSING }); + } + + const session = await sessionManager.getRuntimeSession(documentId, socket.data.sessionDid); + if (!session) { + return callback({ status: false, statusCode: 404, error: "Session not found", errorCode: ErrorCode.SESSION_NOT_FOUND }); + } + + await mongodbStore.upsertMirrorSnapshot({ + documentId, + data, + fileKeyEpoch, + sessionDid: session.sessionDid, + createdAt: Date.now(), + }); + + callback({ status: true, statusCode: 200, data: { ok: true } }); + } catch (error) { + console.error("Error in mirror snapshot handler:", error); + callback({ status: false, statusCode: 500, error: "Internal server error", errorCode: ErrorCode.INTERNAL_ERROR }); + } +} + export async function handleSetDocumentMeta( deps: SocketHandlerDeps, socket: AppSocket, @@ -871,6 +1182,12 @@ export async function handleSetDocumentMeta( title: args.title, }); + const roomName = getRoomName(documentId, socket.data.sessionDid); + socket.to(roomName).emit("/document/meta_update", { + roomId: documentId, + title: args.title, + }); + callback({ status: true, statusCode: 200, @@ -926,6 +1243,7 @@ export async function handlePeersList( } export async function handleAwareness( + deps: SocketHandlerDeps, io: AppServer, socket: AppSocket, args: AwarenessArgs, @@ -942,6 +1260,20 @@ export async function handleAwareness( const roomName = getRoomName(documentId, socket.data.sessionDid); const awarenessPayload = { data, roomId: documentId }; socket.to(roomName).emit("/document/awareness_update", awarenessPayload); + + // Post-broadcast, fire-and-forget: catches revoked-but-idle (reading, cursor-moving) + // actors that never hit the write chokepoints. Kicks are per-actor/rail-off only. + if (socket.data.role !== "owner" && normalizeAppType(socket.data.appType) === "ddoc") { + const now = Date.now(); + if (now - (socket.data.lastAdmitRecheckAt ?? 0) >= ADMIT_RECHECK_INTERVAL_MS) { + socket.data.lastAdmitRecheckAt = now; + void isStillAdmitted(socket, deps) + .then((ok) => { + if (!ok) socket.disconnect(true); + }) + .catch(() => undefined); + } + } } catch (error) { console.error("Error in awareness handler:", error); } @@ -995,6 +1327,17 @@ export async function handleTerminateSession( ownerAddress ); + if ( + ownerDid && + session.portalAddress && + contractAddress && + session.portalAddress.toLowerCase() === String(contractAddress).toLowerCase() && + ownerDid !== session.ownerDid + ) { + await sessionManager.updateSessionOwnerDid(documentId, session.sessionDid, ownerDid); + session.ownerDid = ownerDid; + } + if (ownerDid !== session.ownerDid) { return callback({ status: false, @@ -1041,6 +1384,23 @@ export async function handleTerminateSession( } } +export function handleEpochLoaded( + socket: AppSocket, + args: EpochLoadedArgs, + callback: (r: AckResponse<{ ok: true }>) => void +): void { + if (!requireAuth(socket)) { + return callback({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + } + rotationCoordinator.recordAck(args.documentId || socket.data.documentId, socket.id); + callback({ status: true, statusCode: 200, data: { ok: true } }); +} + export async function handleDisconnecting( deps: SocketHandlerDeps, socket: AppSocket diff --git a/src/tests/cron/gracePurgeJob.test.ts b/src/tests/cron/gracePurgeJob.test.ts new file mode 100644 index 0000000..4dcc832 --- /dev/null +++ b/src/tests/cron/gracePurgeJob.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const purgeTombstonedBefore = vi.fn(); + +vi.mock("../../cron/agenda", () => ({ + agenda: { define: vi.fn(), every: vi.fn() }, +})); + +vi.mock("../../config", () => ({ + config: { deleteGrace: { windowMs: 2592000000, interval: "1 hour", batchSize: 200 } }, +})); + +vi.mock("../../services/mongodb-store", () => ({ + mongodbStore: { purgeTombstonedBefore: (...args: unknown[]) => purgeTombstonedBefore(...args) }, +})); + +import { jobDefinition } from "../../cron/jobs/gracePurgeJob"; +import { config } from "../../config"; + +describe("gracePurgeJob jobDefinition", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("purges docs tombstoned before the grace-window cutoff and signals completion", async () => { + purgeTombstonedBefore.mockResolvedValue(["d1", "d2"]); + const done = vi.fn(); + + await jobDefinition({} as any, done); + + expect(purgeTombstonedBefore).toHaveBeenCalledTimes(1); + expect(purgeTombstonedBefore).toHaveBeenCalledWith( + Date.now() - config.deleteGrace.windowMs, + config.deleteGrace.batchSize + ); + expect(done).toHaveBeenCalledTimes(1); + expect(done.mock.calls[0][0]).toBeUndefined(); + }); + + it("forwards the store error to done on failure", async () => { + const err = new Error("mongo down"); + purgeTombstonedBefore.mockRejectedValue(err); + const done = vi.fn(); + + await jobDefinition({} as any, done); + + expect(done).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts index 760a67e..9875cb5 100644 --- a/src/tests/services/auth.test.ts +++ b/src/tests/services/auth.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as ucans from "@ucans/ucans"; import { AuthService } from "../../services/auth"; -import { getIdentitySigningDid, getOwnerDid } from "../../utils/contract"; +import { getIdentitySigningDid, getOwnerDid, refreshOwnerDid } from "../../utils/contract"; -vi.mock("@ucans/ucans", () => ({ verify: vi.fn() })); +vi.mock("@ucans/ucans", () => ({ verify: vi.fn(), validate: vi.fn() })); vi.mock("../../utils/contract", () => ({ getIdentitySigningDid: vi.fn(), getOwnerDid: vi.fn(), + refreshOwnerDid: vi.fn(), })); describe("verifyCollaborationToken cache (R4a)", () => { @@ -41,12 +42,14 @@ describe("verifyIdentityToken", () => { }); it("verifies the UCAN with rootIssuer = on-chain signingDid and a ddocId-bound capability", async () => { + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ identityContractAddress: "0xIdentity" }] } }); (getIdentitySigningDid as any).mockResolvedValue("did:key:zOwner"); (ucans.verify as any).mockResolvedValue({ ok: true }); const auth = new AuthService("did:server"); - const did = await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A"); + const did = await auth.verifyIdentityToken("tok", "ddoc-A"); + expect(getIdentitySigningDid).toHaveBeenCalledWith("0xIdentity"); expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ audience: "did:server", requiredCapabilities: [expect.objectContaining({ @@ -61,9 +64,56 @@ describe("verifyIdentityToken", () => { }); it("returns null when the on-chain DID cannot be resolved", async () => { + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ identityContractAddress: "0xIdentity" }] } }); (getIdentitySigningDid as any).mockResolvedValue(null); const auth = new AuthService("did:server"); - expect(await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A")).toBeNull(); + expect(await auth.verifyIdentityToken("tok", "ddoc-A")).toBeNull(); + }); +}); + +describe("verifyOwnerToken rotation re-read", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-reads the chain once when the cached DID fails verification and accepts the fresh DID", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:old"); + (refreshOwnerDid as any).mockResolvedValue("did:key:new"); + (ucans.verify as any).mockImplementation((_token: string, opts: any) => + Promise.resolve({ ok: opts.requiredCapabilities[0].rootIssuer === "did:key:new" }) + ); + const auth = new AuthService("did:server"); + + const did = await auth.verifyOwnerToken("tok", "0xContract" as any, "0xCollab" as any); + + expect(did).toBe("did:key:new"); + expect(refreshOwnerDid).toHaveBeenCalledTimes(1); + expect(refreshOwnerDid).toHaveBeenCalledWith("0xContract", "0xCollab"); + }); + + it("rate-limits the re-read per (contract, address)", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:old"); + (refreshOwnerDid as any).mockResolvedValue("did:key:still-wrong"); + (ucans.verify as any).mockResolvedValue({ ok: false }); + const auth = new AuthService("did:server"); + + const first = await auth.verifyOwnerToken("badtok", "0xContract" as any, "0xCollab" as any); + const second = await auth.verifyOwnerToken("badtok", "0xContract" as any, "0xCollab" as any); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(refreshOwnerDid).toHaveBeenCalledTimes(1); + }); + + it("returns the cached DID on the happy path without any re-read", async () => { + (getOwnerDid as any).mockResolvedValue("did:key:owner"); + (ucans.verify as any).mockResolvedValue({ ok: true }); + const auth = new AuthService("did:server"); + + const did = await auth.verifyOwnerToken("tok", "0xContract" as any, "0xCollab" as any); + + expect(did).toBe("did:key:owner"); + expect(refreshOwnerDid).not.toHaveBeenCalled(); }); }); @@ -78,7 +128,7 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zOwner"); const ok = await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xIdentity" as any, + identityToken: "it", }); expect(ok).toBe(true); }); @@ -100,7 +150,7 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zAttacker"); // validly signed, wrong identity const ok = await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xAttacker" as any, + identityToken: "it", }); expect(ok).toBe(false); }); @@ -110,8 +160,60 @@ describe("verifyOwnerOp (OR of two bound proofs)", () => { const spy = vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue(null); await auth.verifyOwnerOp({ ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", - identityToken: "it", identityContractAddress: "0xIdentity" as any, + identityToken: "it", }); - expect(spy).toHaveBeenCalledWith("it", "0xIdentity", "ddoc-A"); + expect(spy).toHaveBeenCalledWith("it", "ddoc-A"); + }); +}); + +describe("verifyEditUcan", () => { + const serverDid = "did:key:zServerTest"; + const gateDid = "did:key:zGateTest"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when the fact carries editGrantEpoch/nullifier but no editHandle (epoch-fact UCANs retired)", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editGrantEpoch: 3, nullifier: "n-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + // rooted at the pinned gate DID + the collab:EDIT capability + expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ + audience: serverDid, + requiredCapabilities: [expect.objectContaining({ + capability: { with: { scheme: "collab", hierPart: "doc-1" }, can: { namespace: "collab", segments: ["EDIT"] } }, + rootIssuer: gateDid, + })], + })); + }); + + it("returns {kind:'actor', editHandle, epoch:0} when the fact carries a matching docId + editHandle but no epoch", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-1", editHandle: "h-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toEqual({ kind: "actor", editHandle: "h-1", epoch: 0 }); + }); + + it("returns null when verify fails (wrong issuer / capability / audience)", async () => { + (ucans.verify as any).mockResolvedValue({ ok: false }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + // verify gates validate: a failed verify must not reach fact extraction. + expect(ucans.validate).not.toHaveBeenCalled(); + }); + + it("returns null when the fact docId disagrees with the requested documentId", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + (ucans.validate as any).mockResolvedValue({ payload: { fct: [{ docId: "doc-OTHER", editGrantEpoch: 3, nullifier: "n-1" }] } }); + const svc = new AuthService(serverDid, gateDid); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + }); + + it("returns null (short-circuit, no verify call) when no gate DID is pinned", async () => { + const svc = new AuthService(serverDid, undefined); + expect(await svc.verifyEditUcan("tok", "doc-1")).toBeNull(); + expect(ucans.verify).not.toHaveBeenCalled(); }); }); diff --git a/src/tests/services/auth.verifyEditUcan.epoch.test.ts b/src/tests/services/auth.verifyEditUcan.epoch.test.ts new file mode 100644 index 0000000..bc59a48 --- /dev/null +++ b/src/tests/services/auth.verifyEditUcan.epoch.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import * as ucans from "@ucans/ucans"; +import { AuthService } from "../../services/auth"; + +async function mint(gate: ucans.EdKeypair, serverDid: string, facts: Record[], docId: string) { + const u = await ucans.build({ + issuer: gate, audience: serverDid, + capabilities: [{ with: { scheme: "collab", hierPart: docId }, can: { namespace: "collab", segments: ["EDIT"] } }], + facts, lifetimeInSeconds: 3600, + }); + return ucans.encode(u); +} + +describe("verifyEditUcan epoch fact", () => { + it("returns the epoch from the fact", async () => { + const gate = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did(), gate.did()); + const token = await mint(gate, server.did(), [{ docId: "doc-1", editHandle: "h1", epoch: 3 }], "doc-1"); + expect(await svc.verifyEditUcan(token, "doc-1")).toEqual({ kind: "actor", editHandle: "h1", epoch: 3 }); + }); + + it("defaults epoch to 0 when the fact omits it (pre-Plan-1 UCAN)", async () => { + const gate = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did(), gate.did()); + const token = await mint(gate, server.did(), [{ docId: "doc-1", editHandle: "h1" }], "doc-1"); + expect(await svc.verifyEditUcan(token, "doc-1")).toEqual({ kind: "actor", editHandle: "h1", epoch: 0 }); + }); +}); diff --git a/src/tests/services/auth.verifyIdentityToken.fact.test.ts b/src/tests/services/auth.verifyIdentityToken.fact.test.ts new file mode 100644 index 0000000..a3b5375 --- /dev/null +++ b/src/tests/services/auth.verifyIdentityToken.fact.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as ucans from "@ucans/ucans"; +import { AuthService } from "../../services/auth"; +import { getIdentitySigningDid } from "../../utils/contract"; + +// Real round-trip: mint the same shape the client produces (facts, not an argument) and +// verify against a mocked chain read. See docs/architecture/edit-permission.md. +vi.mock("../../utils/contract", () => ({ getIdentitySigningDid: vi.fn() })); + +async function mint(issuer: ucans.EdKeypair, serverDid: string, ddocId: string, facts: Record[]) { + const u = await ucans.build({ + issuer, + audience: serverDid, + capabilities: [ + { + with: { scheme: "storage", hierPart: ddocId }, + can: { namespace: "collaboration", segments: ["OWN"] }, + }, + ], + facts, + lifetimeInSeconds: 3600, + }); + return ucans.encode(u); +} + +describe("verifyIdentityToken (UCAN fact round-trip)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("verifies and returns the on-chain signingDid when the fact + rootIssuer both check out", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + (getIdentitySigningDid as any).mockResolvedValue(issuer.did()); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBe(issuer.did()); + expect(getIdentitySigningDid).toHaveBeenCalledWith("0xabc0000000000000000000000000000000abc0"); + }); + + it("returns null when the token was minted without the identity fact", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", []); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + expect(getIdentitySigningDid).not.toHaveBeenCalled(); + }); + + it("returns null when the fact's address is unregistered on-chain (getIdentitySigningDid → null)", async () => { + const issuer = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + (getIdentitySigningDid as any).mockResolvedValue(null); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + }); + + it("returns null when the token's issuer does not root at the on-chain signingDid (wrong rootIssuer)", async () => { + const issuer = await ucans.EdKeypair.create(); + const attacker = await ucans.EdKeypair.create(); + const server = await ucans.EdKeypair.create(); + const svc = new AuthService(server.did()); + const token = await mint(issuer, server.did(), "ddoc-1", [{ identityContractAddress: "0xabc0000000000000000000000000000000abc0" }]); + + // The chain says the address's signing key is the ATTACKER's DID, not the token's issuer. + (getIdentitySigningDid as any).mockResolvedValue(attacker.did()); + + expect(await svc.verifyIdentityToken(token, "ddoc-1")).toBeNull(); + }); +}); diff --git a/src/tests/services/deleted-file-webhook.test.ts b/src/tests/services/deleted-file-webhook.test.ts new file mode 100644 index 0000000..5088156 --- /dev/null +++ b/src/tests/services/deleted-file-webhook.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { findById: (...a: unknown[]) => findById(...a) }, + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +vi.mock("../../config", () => ({ + config: { webhook: { apiKey: "test-webhook-secret" } }, +})); + +import { createDeletedFileWebhookHandler } from "../../services/deleted-file-webhook"; +import { config } from "../../config"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +function req(headers: Record, body: unknown) { + return { + header: (name: string) => headers[name.toLowerCase()], + body, + } as any; +} + +describe("POST /webhooks/file-deleted", () => { + let mongodbStore: any; + let onTombstoned: ReturnType>>; + + beforeEach(() => { + vi.clearAllMocks(); + mongodbStore = { tombstoneDocument: vi.fn().mockResolvedValue(true) }; + onTombstoned = vi.fn<[string], Promise>().mockResolvedValue(undefined); + }); + + it("tombstones and drops sessions on valid secret + portal match", async () => { + findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xAbC" }) }), + }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(mongodbStore.tombstoneDocument).toHaveBeenCalledWith("doc-1", "onchain-delete"); + expect(onTombstoned).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: true }); + }); + + it("401s on wrong secret and never tombstones", async () => { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "wrong-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(r.status).toHaveBeenCalledWith(401); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + }); + + it("no-ops on portal mismatch (colliding-appFileId defense) and never tombstones", async () => { + findById.mockReturnValue({ + select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xOwnerPortal" }) }), + }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xAttackerPortal" }), + r + ); + + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: false }); + }); + + it("no-ops on unknown appFileId and never tombstones", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "missing", portalAddress: "0xabc" }), + r + ); + + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, matched: false }); + }); + + it("401s when config.webhook.apiKey is unset, even with a header, and never tombstones", async () => { + const original = config.webhook.apiKey; + config.webhook.apiKey = undefined; + try { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler( + req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1", portalAddress: "0xabc" }), + r + ); + + expect(r.status).toHaveBeenCalledWith(401); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + } finally { + config.webhook.apiKey = original; + } + }); + + it("400s when appFileId or portalAddress is missing", async () => { + const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned }); + const r = res(); + + await handler(req({ "x-webhook-api-key": "test-webhook-secret" }, { appFileId: "doc-1" }), r); + + expect(r.status).toHaveBeenCalledWith(400); + expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + expect(findById).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts index e8f8ab9..61e0860 100644 --- a/src/tests/services/flush-route.test.ts +++ b/src/tests/services/flush-route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createFlushHandler, FLUSH_MAX_BYTES } from "../../services/flush-route"; +import { SessionTerminatedError } from "../../services/mongodb-store"; function res() { const r: any = {}; @@ -44,4 +45,15 @@ describe("POST /flush", () => { })); expect(r.status).toHaveBeenCalledWith(200); }); + + it("409s with an error body (not 200) when createUpdate rejects with SessionTerminatedError", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + deps.mongodbStore.createUpdate.mockRejectedValue(new SessionTerminatedError()); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct" } } as any, r); + + expect(r.status).toHaveBeenCalledWith(409); + expect(r.status).not.toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) })); + }); }); diff --git a/src/tests/services/mongodb-store.minEditEpoch.test.ts b/src/tests/services/mongodb-store.minEditEpoch.test.ts new file mode 100644 index 0000000..d78cf4a --- /dev/null +++ b/src/tests/services/mongodb-store.minEditEpoch.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findOneAndUpdate = vi.fn(); +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentEditEpochModel: { + findOneAndUpdate: (...a: unknown[]) => findOneAndUpdate(...a), + findById: (...a: unknown[]) => findById(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMetaModel: {}, DocumentMirrorModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("minEditEpoch store", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("setMinEditEpoch upserts with a monotonic $max", async () => { + findOneAndUpdate.mockResolvedValue({}); + await store.setMinEditEpoch("doc-1", 5); + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "doc-1" }, + { $max: { minEditEpoch: 5 } }, + { upsert: true } + ); + }); + + it("getMinEditEpoch returns the stored value", async () => { + findById.mockReturnValue({ lean: () => Promise.resolve({ minEditEpoch: 7 }) }); + expect(await store.getMinEditEpoch("doc-1")).toBe(7); + }); + + it("getMinEditEpoch returns 0 when the doc has no floor yet", async () => { + findById.mockReturnValue({ lean: () => Promise.resolve(null) }); + expect(await store.getMinEditEpoch("doc-x")).toBe(0); + }); +}); diff --git a/src/tests/services/mongodb-store.pinPortal.test.ts b/src/tests/services/mongodb-store.pinPortal.test.ts new file mode 100644 index 0000000..9614ee5 --- /dev/null +++ b/src/tests/services/mongodb-store.pinPortal.test.ts @@ -0,0 +1,161 @@ +// HONESTY NOTE: real first-writer-wins immutability is enforced by Mongo `_id` +// uniqueness + `$setOnInsert`, which this mock cannot exercise. These tests verify +// the store ISSUES the correct atomic operators — the accepted test level for this +// repo's mocked-Mongoose store suite (see mongodb-store.minEditEpoch.test.ts). +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findOneAndUpdate = vi.fn(); +const updateOne = vi.fn(); +const findById = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { + findOneAndUpdate: (...a: unknown[]) => findOneAndUpdate(...a), + updateOne: (...a: unknown[]) => updateOne(...a), + findById: (...a: unknown[]) => findById(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("pinDocumentPortalIfAbsent", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + findOneAndUpdate.mockResolvedValue({}); + updateOne.mockResolvedValue({}); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + }); + + it("pins the binding fields via $setOnInsert, never $set", async () => { + await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "d1" }, + { + $setOnInsert: expect.objectContaining({ + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }), + }, + { upsert: true, writeConcern: { w: "majority", j: true } } + ); + const [, updatePayload] = findOneAndUpdate.mock.calls[0]; + expect(updatePayload.$set).toBeUndefined(); + }); + + it("issues the backfill updateOne with the null-guard filter", async () => { + await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xP1", + ownerDid: "did:key:o", + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s1", + }); + + expect(updateOne).toHaveBeenCalledWith( + { _id: "d1", $or: [{ portalAddress: null }, { portalAddress: { $exists: false } }] }, + { $set: { portalAddress: "0xP1" } } + ); + }); + + it("returns the effective (post-insert) portalAddress", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "d1", + portalAddress: "0xATTACKER", + ownerDid: "did:key:o2", + ownerIdentityDid: "did:key:i2", + sessionDid: "did:key:s2", + }); + + // The caller's own portal is irrelevant to the return value — the effective + // pin comes from the post-backfill read, which the mock stubs as "0xP1". + expect(result).toEqual({ portalAddress: "0xP1" }); + }); + + it("legacy null-portal row: backfill runs, caller's portal wins the return (not null)", async () => { + // Simulates a pre-existing row with portalAddress: null — the backfill updateOne + // (asserted above) is what actually sets it; here we assert the read-back reflects + // the caller's portal rather than the mock returning a lockout `null`. + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xP1" }) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "legacy-doc", + portalAddress: "0xP1", + ownerDid: null, + ownerIdentityDid: "did:key:i", + sessionDid: "did:key:s", + }); + + expect(result.portalAddress).toBe("0xP1"); + expect(result.portalAddress).not.toBeNull(); + }); + + it("returns null when no row is found at all", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + + const result = await store.pinDocumentPortalIfAbsent({ + documentId: "missing", + portalAddress: "0xP1", + ownerDid: null, + ownerIdentityDid: null, + sessionDid: "did:key:s", + }); + + expect(result).toEqual({ portalAddress: null }); + }); +}); + +describe("upsertDocumentMeta binding immutability", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + findOneAndUpdate.mockResolvedValue({}); + }); + + it("issues $setOnInsert for binding fields and $set for mutable fields", async () => { + await store.upsertDocumentMeta({ + documentId: "d2", + sessionDid: "did:key:s", + ownerDid: "did:key:x", + ownerIdentityDid: "did:key:x", + portalAddress: "0xEVIL", + editLock: "lk", + title: "t", + }); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { _id: "d2" }, + { + $setOnInsert: { + ownerDid: "did:key:x", + ownerIdentityDid: "did:key:x", + portalAddress: "0xEVIL", + }, + $set: expect.objectContaining({ + sessionDid: "did:key:s", + editLock: "lk", + title: "t", + }), + }, + { upsert: true, new: true, writeConcern: { w: "majority", j: true } } + ); + const [, updatePayload] = findOneAndUpdate.mock.calls[0]; + expect(updatePayload.$set.ownerDid).toBeUndefined(); + expect(updatePayload.$set.ownerIdentityDid).toBeUndefined(); + expect(updatePayload.$set.portalAddress).toBeUndefined(); + }); +}); diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 10472bb..6dd25f5 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { DocumentUpdateModel } from "../../database/models/document-update"; import { CounterModel } from "../../database/models/counter"; -import { MongoDBStore } from "../../services/mongodb-store"; +import { MongoDBStore, SessionTerminatedError } from "../../services/mongodb-store"; vi.mock("../../database/models", () => { const save = vi.fn().mockResolvedValue(undefined); @@ -21,7 +21,13 @@ vi.mock("../../database/models", () => { DocumentCommitModel, CounterModel: { findOneAndUpdate: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, SessionModel: { findOne: vi.fn(), find: vi.fn(), deleteMany: vi.fn().mockResolvedValue(undefined) }, - DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), deleteOne: vi.fn().mockResolvedValue(undefined) }, + DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), updateOne: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, + DocumentMirrorModel: { + findOneAndUpdate: vi.fn().mockResolvedValue(undefined), + findOne: vi.fn(), + deleteMany: vi.fn().mockResolvedValue(undefined), + }, + DocumentEditEpochModel: { deleteOne: vi.fn().mockResolvedValue(undefined) }, }; }); @@ -59,19 +65,22 @@ describe("createUpdate: durable-write gate", () => { (SessionModel.findOne as any).mockResolvedValue({ state: "active", ownerDid: "did:o" }); }); - it("relays without persisting when no non-terminated session backs the room", async () => { - const { SessionModel, DocumentUpdateModel } = await import("../../database/models"); + it("relays without persisting when no session row backs the room (missing row is NOT terminated — D-11 is terminated-only)", async () => { + const { CounterModel, SessionModel, DocumentUpdateModel } = await import("../../database/models"); (SessionModel.findOne as any).mockResolvedValue(null); const save = vi.fn(); (DocumentUpdateModel as any).mockImplementation((doc: any) => ({ ...doc, save })); const store = new MongoDBStore(); - const result = await store.createUpdate({ - id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", - committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", - }); + const input = { + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update" as const, + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc" as const, + }; + const result = await store.createUpdate(input); + expect(result).toEqual(input); expect(save).not.toHaveBeenCalled(); + expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); expect(result.seq).toBeUndefined(); }); @@ -84,18 +93,19 @@ describe("createUpdate: durable-write gate", () => { expect(result.seq).toBe(1); }); - it("relays without persisting when the backing session is terminated", async () => { + it("rejects with SessionTerminatedError when the backing session is terminated (D-11: terminated-only, no ephemeral relay)", async () => { const { CounterModel, SessionModel } = await import("../../database/models"); (SessionModel.findOne as any).mockResolvedValue({ state: "terminated" }); const store = new MongoDBStore(); - const result = await store.createUpdate({ - id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", - committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", - }); + await expect( + store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", + }) + ).rejects.toThrow(SessionTerminatedError); expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); - expect(result.seq).toBeUndefined(); }); }); @@ -151,6 +161,40 @@ describe("createSnapshot: keep-latest", () => { }); }); +describe("mirror lane", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("upserts keyed by (documentId, fileKeyEpoch), overwrite-in-place with NO destructive prune", async () => { + const { DocumentMirrorModel } = await import("../../database/models"); + const store = new MongoDBStore(); + await store.upsertMirrorSnapshot({ documentId: "doc-1", data: "ct", fileKeyEpoch: 4, sessionDid: "sess-1", createdAt: 111 }); + + expect(DocumentMirrorModel.findOneAndUpdate).toHaveBeenCalledWith( + { documentId: "doc-1", fileKeyEpoch: 4 }, + { $set: { data: "ct", createdAt: 111, authorSessionDid: "sess-1" } }, + { upsert: true } + ); + // A client-asserted epoch must NEVER trigger a delete of other rows. + expect(DocumentMirrorModel.deleteMany).not.toHaveBeenCalled(); + }); + + it("getLatestMirror returns the most recent row (by createdAt) or null", async () => { + const { DocumentMirrorModel } = await import("../../database/models"); + const lean = vi.fn().mockResolvedValue({ documentId: "doc-1", fileKeyEpoch: 9, data: "ct", createdAt: 222 }); + const sort = vi.fn().mockReturnValue({ lean }); + (DocumentMirrorModel.findOne as any).mockReturnValue({ sort }); + const store = new MongoDBStore(); + expect(await store.getLatestMirror("doc-1")).toEqual({ data: "ct", fileKeyEpoch: 9, createdAt: 222 }); + expect(DocumentMirrorModel.findOne).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(sort).toHaveBeenCalledWith({ createdAt: -1 }); + + (DocumentMirrorModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); + expect(await store.getLatestMirror("doc-1")).toBeNull(); + }); +}); + describe("upsertDocumentMeta", () => { beforeEach(() => { vi.clearAllMocks(); @@ -169,9 +213,12 @@ describe("upsertDocumentMeta", () => { expect(DocumentMetaModel.findOneAndUpdate).toHaveBeenCalledWith( { _id: "doc-1" }, expect.objectContaining({ + // Binding fields are first-writer-immutable (C1) — pinned on insert only. + $setOnInsert: expect.objectContaining({ + ownerDid: "od", ownerIdentityDid: "oid", portalAddress: "0xP", + }), $set: expect.objectContaining({ - sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", - portalAddress: "0xP", editLock: "el", title: "t", + sessionDid: "room-did", editLock: "el", title: "t", }), }), expect.objectContaining({ upsert: true, writeConcern: { w: "majority", j: true } }) @@ -369,8 +416,8 @@ describe("purgeDocument", () => { vi.clearAllMocks(); }); - it("wipes all five collections for the documentId", async () => { - const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel } = + it("wipes all seven collections for the documentId", async () => { + const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel, DocumentEditEpochModel } = await import("../../database/models"); const store = new MongoDBStore(); @@ -381,6 +428,8 @@ describe("purgeDocument", () => { expect(DocumentMetaModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); expect(SessionModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); expect(CounterModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); + expect(DocumentMirrorModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(DocumentEditEpochModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); }); }); @@ -430,6 +479,49 @@ describe("collectOrphans", () => { }); }); +describe("getShareContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reports existence and isPublished", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.findById as any).mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ isPublished: false }), + }), + }); + + const store = new MongoDBStore(); + expect(await store.getShareContext("doc-sc-none")).toEqual({ + exists: false, isPublished: false, + }); + expect(DocumentMetaModel.findById).toHaveBeenCalledWith("doc-sc-none"); + + expect(await store.getShareContext("doc-sc-2")).toEqual({ + exists: true, isPublished: false, + }); + }); + + it("reports isPublished true only for a strict boolean", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.findById as any).mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn() + .mockResolvedValueOnce({ isPublished: true }) + .mockResolvedValueOnce({}), + }), + }); + + const store = new MongoDBStore(); + expect((await store.getShareContext("doc-sc-pub")).isPublished).toBe(true); + // legacy row with no isPublished field normalizes to false + expect((await store.getShareContext("doc-sc-legacy")).isPublished).toBe(false); + }); +}); + describe("markUpdatesAsCommitted scoping", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/tests/services/mongodb-store.tombstone.test.ts b/src/tests/services/mongodb-store.tombstone.test.ts new file mode 100644 index 0000000..e9fd9e4 --- /dev/null +++ b/src/tests/services/mongodb-store.tombstone.test.ts @@ -0,0 +1,109 @@ +// HONESTY NOTE: real once-only tombstone semantics come from the Mongo filter +// `{ tombstonedAt: null }` on `updateOne`, which this mock cannot exercise. These +// tests verify the store ISSUES the correct queries/operators — the accepted test +// level for this repo's mocked-Mongoose store suite (see mongodb-store.minEditEpoch.test.ts). +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const updateOne = vi.fn(); +const findById = vi.fn(); +const find = vi.fn(); + +vi.mock("../../database/models", () => ({ + DocumentMetaModel: { + updateOne: (...a: unknown[]) => updateOne(...a), + findById: (...a: unknown[]) => findById(...a), + find: (...a: unknown[]) => find(...a), + }, + // other models referenced by mongodb-store's import — stub as empty objects + DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {}, + SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {}, +})); + +import { MongoDBStore } from "../../services/mongodb-store"; + +describe("tombstoneDocument", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sets tombstonedAt/tombstoneReason on a live doc and returns true", async () => { + updateOne.mockResolvedValue({ matchedCount: 1 }); + + const result = await store.tombstoneDocument("d1", "on-chain-delete"); + + expect(updateOne).toHaveBeenCalledWith( + { _id: "d1", tombstonedAt: null }, + { $set: { tombstonedAt: expect.any(Number), tombstoneReason: "on-chain-delete" } } + ); + expect(result).toBe(true); + }); + + it("is idempotent: already-tombstoned doc still returns true", async () => { + updateOne.mockResolvedValue({ matchedCount: 0 }); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ tombstonedAt: 123 }) }) }); + + const result = await store.tombstoneDocument("d1", "on-chain-delete"); + + expect(result).toBe(true); + }); + + it("returns false when no row matches and the doc doesn't exist", async () => { + updateOne.mockResolvedValue({ matchedCount: 0 }); + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + + const result = await store.tombstoneDocument("missing", "on-chain-delete"); + + expect(result).toBe(false); + }); +}); + +describe("isTombstoned", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when tombstonedAt is set", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ tombstonedAt: 123 }) }) }); + expect(await store.isTombstoned("d1")).toBe(true); + }); + + it("returns false when tombstonedAt is null/absent", async () => { + findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) }); + expect(await store.isTombstoned("d1")).toBe(false); + }); +}); + +describe("purgeTombstonedBefore", () => { + const store = new MongoDBStore(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("purges only rows past the cutoff via purgeDocument, and returns their ids", async () => { + find.mockReturnValue({ + select: () => ({ limit: () => ({ lean: () => Promise.resolve([{ _id: "d1" }, { _id: "d2" }]) }) }), + }); + const purgeDocument = vi.spyOn(store, "purgeDocument").mockResolvedValue(undefined); + + const cutoffMs = 1000; + const result = await store.purgeTombstonedBefore(cutoffMs, 50); + + expect(find).toHaveBeenCalledWith({ tombstonedAt: { $ne: null, $lte: cutoffMs } }); + expect(purgeDocument).toHaveBeenCalledWith("d1"); + expect(purgeDocument).toHaveBeenCalledWith("d2"); + expect(purgeDocument).toHaveBeenCalledTimes(2); + expect(result).toEqual(["d1", "d2"]); + }); + + it("returns an empty array when nothing is past the cutoff", async () => { + find.mockReturnValue({ select: () => ({ limit: () => ({ lean: () => Promise.resolve([]) }) }) }); + const purgeDocument = vi.spyOn(store, "purgeDocument").mockResolvedValue(undefined); + + const result = await store.purgeTombstonedBefore(1000, 50); + + expect(purgeDocument).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts index b3536c3..86e629d 100644 --- a/src/tests/services/owner-op-routes.test.ts +++ b/src/tests/services/owner-op-routes.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler } from "../../services/owner-op-routes"; +import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler, createWorkspaceEditTierHandler } from "../../services/owner-op-routes"; +import { createMirrorReadHandler, createEvictWorkspaceMemberHandler, createEvictEditActorsHandler } from "../../services/owner-op-routes"; +import { createShareContextHandler } from "../../services/owner-op-routes"; +import { getRoomName } from "../../services/socket-handlers"; +import { getPortalOwnerAddress, bustOwnerDidCacheForPortal } from "../../utils/contract"; + +vi.mock("../../utils/contract", () => ({ + getPortalOwnerAddress: vi.fn(), + bustOwnerDidCacheForPortal: vi.fn(), +})); function res() { const r: any = {}; @@ -77,6 +86,105 @@ describe("POST collab-join-enabled", () => { }); }); +describe("POST workspace-edit-tier", () => { + let deps: any, io: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn(), + setWorkspaceEditEnabled: vi.fn(), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([]), + }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("403s when verifyOwnerOp rejects", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: true } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.sessionManager.setWorkspaceEditEnabled).not.toHaveBeenCalled(); + }); + + it("enables the tier for the proven owner", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s" }]); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: true } } as any, r + ); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s", true); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("on disable, force-drops ONLY workspace-rail sockets (not public editors or owners)", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s" }]); + const wsSock: any = { data: { role: "editor", rail: "workspace" }, disconnect: vi.fn() }; + const pubSock: any = { data: { role: "editor", rail: "public" }, disconnect: vi.fn() }; + const ownerSock: any = { data: { role: "owner" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([wsSock, pubSock, ownerSock]) })); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: false } } as any, r + ); + expect(wsSock.disconnect).toHaveBeenCalledWith(true); + expect(pubSock.disconnect).not.toHaveBeenCalled(); + expect(ownerSock.disconnect).not.toHaveBeenCalled(); + }); + + it("writes the flag to every non-terminated session plus the request one (doc-wide)", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s-a" }, { sessionDid: "s-b" }]); + const r = res(); + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-a", true); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-b", true); + expect(deps.sessionManager.setWorkspaceEditEnabled).toHaveBeenCalledWith("doc-1", "s-req", true); + }); + + it("returns {ok, updated} counting the sessions actually flipped", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([{ sessionDid: "s-a" }]); + deps.sessionManager.setWorkspaceEditEnabled.mockResolvedValue(true); + const r = res(); + + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, updated: 2 }); + }); + + it("reports updated: 0 when no session row is actually flipped", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s-req", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([]); + deps.sessionManager.setWorkspaceEditEnabled.mockResolvedValue(false); + const r = res(); + + await createWorkspaceEditTierHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-req", enabled: true } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, updated: 0 }); + }); +}); + describe("POST /list-my-documents", () => { let deps: any; beforeEach(() => { @@ -91,7 +199,7 @@ describe("POST /list-my-documents", () => { deps.authService.verifyIdentityToken.mockResolvedValue("did:key:zOwner"); const r = res(); await createListMyDocumentsHandler(deps)( - { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + { body: { identityToken: "it", portalAddress: "0xP" } } as any, r ); expect(deps.mongodbStore.listDocumentsForOwner).toHaveBeenCalledWith({ ownerIdentityDid: "did:key:zOwner" }); expect(r.status).toHaveBeenCalledWith(200); @@ -109,7 +217,7 @@ describe("POST /list-my-documents", () => { deps.authService.verifyIdentityToken.mockResolvedValue(null); const r = res(); await createListMyDocumentsHandler(deps)( - { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + { body: { identityToken: "it", portalAddress: "0xP" } } as any, r ); expect(r.status).toHaveBeenCalledWith(401); expect(deps.mongodbStore.listDocumentsForOwner).not.toHaveBeenCalled(); @@ -122,7 +230,7 @@ describe("DELETE /documents/:id", () => { deps = { authService: { verifyOwnerOp: vi.fn() }, sessionManager: { getSession: vi.fn() }, - mongodbStore: { purgeDocument: vi.fn() }, + mongodbStore: { tombstoneDocument: vi.fn(), purgeDocument: vi.fn() }, }; }); @@ -131,15 +239,16 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(403); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); }); - it("purges everything for the two-proof owner", async () => { + it("tombstones (never purges) for the two-proof owner", async () => { deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); deps.authService.verifyOwnerOp.mockResolvedValue(true); const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); - expect(deps.mongodbStore.purgeDocument).toHaveBeenCalledWith("d"); + expect(deps.mongodbStore.tombstoneDocument).toHaveBeenCalledWith("d", "owner-delete"); + expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); expect(r.status).toHaveBeenCalledWith(200); }); @@ -149,7 +258,7 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(403); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); }); it("404s when there is no session for the document", async () => { @@ -157,6 +266,358 @@ describe("DELETE /documents/:id", () => { const r = res(); await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); expect(r.status).toHaveBeenCalledWith(404); - expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + expect(deps.mongodbStore.tombstoneDocument).not.toHaveBeenCalled(); + }); +}); + +describe("GET /documents/:id/mirror", () => { + it("returns the latest mirror snapshot", async () => { + const deps: any = { + mongodbStore: { + getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }), + isTombstoned: vi.fn().mockResolvedValue(false), + }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.getLatestMirror).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ data: "ct", fileKeyEpoch: 3, createdAt: 9 }); + }); + + it("404s when there is no mirror yet", async () => { + const deps: any = { + mongodbStore: { getLatestMirror: vi.fn().mockResolvedValue(null), isTombstoned: vi.fn().mockResolvedValue(false) }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(r.status).toHaveBeenCalledWith(404); + }); + + it("404s a tombstoned doc without touching the mirror fetch", async () => { + const deps: any = { + mongodbStore: { getLatestMirror: vi.fn(), isTombstoned: vi.fn().mockResolvedValue(true) }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.isTombstoned).toHaveBeenCalledWith("doc-1"); + expect(deps.mongodbStore.getLatestMirror).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(404); + expect(r.json).toHaveBeenCalledWith({ error: "Not found" }); + }); + + it("proceeds to the normal read when the doc is not tombstoned", async () => { + const deps: any = { + mongodbStore: { + getLatestMirror: vi.fn().mockResolvedValue({ data: "ct", fileKeyEpoch: 1, createdAt: 1 }), + isTombstoned: vi.fn().mockResolvedValue(false), + }, + }; + const r = res(); + await createMirrorReadHandler(deps)({ params: { documentId: "doc-1" } } as any, r); + expect(deps.mongodbStore.getLatestMirror).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + }); +}); + +describe("createEvictWorkspaceMemberHandler", () => { + const portalAddress = "0x0000000000000000000000000000000000000002"; + const ownerAddress = "0x0000000000000000000000000000000000000001"; // Portal.owner() (ASA) + const msaAddress = "0x0000000000000000000000000000000000000003"; // shared member DID address — must NOT pass + let deps: any, io: any; + + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyOwnerToken: vi.fn() }, + sessionManager: { getNonTerminatedSessionsForPortal: vi.fn().mockResolvedValue([]) }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + (getPortalOwnerAddress as any).mockResolvedValue(ownerAddress); + }); + + it("drops only sockets whose actorIdentityDid matches, across all portal sessions", async () => { + deps.authService.verifyOwnerToken.mockResolvedValue("did:key:owner"); + deps.sessionManager.getNonTerminatedSessionsForPortal.mockResolvedValue([ + { documentId: "doc1", sessionDid: "sess1" }, + { documentId: "doc2", sessionDid: "sess2" }, + ]); + const matchSock: any = { data: { actorIdentityDid: "did:key:member" }, disconnect: vi.fn() }; + const otherMemberSock: any = { data: { actorIdentityDid: "did:key:other" }, disconnect: vi.fn() }; + const undefinedDidSock: any = { data: {}, disconnect: vi.fn() }; + const otherRoomSock: any = { data: { actorIdentityDid: "did:key:other2" }, disconnect: vi.fn() }; + const byRoom: Record = { + [getRoomName("doc1", "sess1")]: [matchSock, otherMemberSock, undefinedDidSock], + [getRoomName("doc2", "sess2")]: [otherRoomSock], + }; + io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress }, + } as any, + r + ); + + expect(matchSock.disconnect).toHaveBeenCalledWith(true); + expect(otherMemberSock.disconnect).not.toHaveBeenCalled(); + expect(undefinedDidSock.disconnect).not.toHaveBeenCalled(); + expect(otherRoomSock.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, sessions: 2, dropped: 1 }); + expect(bustOwnerDidCacheForPortal).toHaveBeenCalledWith(portalAddress); + }); + + it("403s when ownerAddress is not Portal.owner() (e.g. the MSA)", async () => { + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress: msaAddress }, + } as any, + r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(r.json).toHaveBeenCalledWith({ error: "Not the portal owner" }); + expect(deps.authService.verifyOwnerToken).not.toHaveBeenCalled(); + expect(deps.sessionManager.getNonTerminatedSessionsForPortal).not.toHaveBeenCalled(); + }); + + it("403s when the owner token does not verify", async () => { + deps.authService.verifyOwnerToken.mockResolvedValue(null); + const r = res(); + + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "bad-tok", ownerAddress }, + } as any, + r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(r.json).toHaveBeenCalledWith({ error: "Owner token verification failed" }); + expect(deps.sessionManager.getNonTerminatedSessionsForPortal).not.toHaveBeenCalled(); + expect(bustOwnerDidCacheForPortal).not.toHaveBeenCalled(); + }); + + it("400s on malformed memberIdentityDid or addresses", async () => { + const r1 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "not-a-did", ownerToken: "tok", ownerAddress }, + } as any, + r1 + ); + expect(r1.status).toHaveBeenCalledWith(400); + expect(r1.json).toHaveBeenCalledWith({ error: "memberIdentityDid must be a DID string" }); + + const r2 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress: "not-an-address" }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress }, + } as any, + r2 + ); + expect(r2.status).toHaveBeenCalledWith(400); + expect(r2.json).toHaveBeenCalledWith({ error: "Invalid portal or owner address" }); + + const r3 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerToken: "tok", ownerAddress: "not-an-address" }, + } as any, + r3 + ); + expect(r3.status).toHaveBeenCalledWith(400); + expect(r3.json).toHaveBeenCalledWith({ error: "Invalid portal or owner address" }); + + const r4 = res(); + await createEvictWorkspaceMemberHandler(deps, io)( + { + params: { portalAddress }, + body: { memberIdentityDid: "did:key:member", ownerAddress }, + } as any, + r4 + ); + expect(r4.status).toHaveBeenCalledWith(400); + expect(r4.json).toHaveBeenCalledWith({ error: "ownerToken is required" }); + + // None of the malformed-input cases should have reached the chain read. + expect(getPortalOwnerAddress).not.toHaveBeenCalled(); + }); +}); + +describe("createEvictEditActorsHandler", () => { + let deps: any, io: any, editBoundCache: any; + beforeEach(() => { + vi.clearAllMocks(); + editBoundCache = { evict: vi.fn() }; + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn().mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }), + getNonTerminatedSessionsForDocument: vi.fn().mockResolvedValue([{ sessionDid: "s" }]), + }, + editBoundCache, + mongodbStore: { setMinEditEpoch: vi.fn() }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("busts the cache and disconnects ONLY matching gp-actor sockets", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const target: any = { data: { role: "editor", railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const otherActor: any = { data: { role: "editor", railKind: "gp-actor", actorHandle: "h2" }, disconnect: vi.fn() }; + const ws: any = { data: { role: "editor", railKind: "workspace", actorHandle: "0xabc" }, disconnect: vi.fn() }; + const owner: any = { data: { role: "owner" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([target, otherActor, ws, owner]) })); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(editBoundCache.evict).toHaveBeenCalledWith("doc-1", ["h1"]); + expect(target.disconnect).toHaveBeenCalledWith(true); + expect(otherActor.disconnect).not.toHaveBeenCalled(); + expect(ws.disconnect).not.toHaveBeenCalled(); + expect(owner.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 1, dropped: 1 }); + }); + + it("sweeps every non-terminated session room of the doc", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + deps.sessionManager.getNonTerminatedSessionsForDocument.mockResolvedValue([ + { sessionDid: "s-a" }, { sessionDid: "s-b" }, + ]); + const inA: any = { data: { railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const inB: any = { data: { railKind: "gp-actor", actorHandle: "h1" }, disconnect: vi.fn() }; + const byRoom: Record = { + [getRoomName("doc-1", "s-a")]: [inA], + [getRoomName("doc-1", "s-b")]: [inB], + }; + io.in = vi.fn((room: string) => ({ fetchSockets: vi.fn().mockResolvedValue(byRoom[room] ?? []) })); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s-a", handles: ["h1"] } } as any, r + ); + expect(inA.disconnect).toHaveBeenCalledWith(true); + expect(inB.disconnect).toHaveBeenCalledWith(true); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 1, dropped: 2 }); + }); + + it("skips the sweep entirely for an empty handle list", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: [] } } as any, r + ); + expect(io.in).not.toHaveBeenCalled(); + expect(r.json).toHaveBeenCalledWith({ ok: true, evicted: 0, dropped: 0 }); + }); + + it("403s a non-owner without touching cache or sockets", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(editBoundCache.evict).not.toHaveBeenCalled(); + expect(io.in).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(403); + }); + + it("stamps minEditEpoch when the client sends a gateEpoch", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: 4 } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).toHaveBeenCalledWith("doc-1", 4); + }); + + it("does not stamp minEditEpoch when gateEpoch is absent", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"] } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + }); + + it("does not stamp minEditEpoch when gateEpoch is NaN, but the evict still proceeds", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: NaN } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + expect(editBoundCache.evict).toHaveBeenCalledWith("doc-1", ["h1"]); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("does not stamp minEditEpoch when gateEpoch is negative or non-integer", async () => { + deps.authService.verifyOwnerOp.mockResolvedValue(true); + for (const badEpoch of [-1, 1.5]) { + deps.mongodbStore.setMinEditEpoch.mockClear(); + const r = res(); + await createEvictEditActorsHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", handles: ["h1"], gateEpoch: badEpoch } } as any, r + ); + expect(deps.mongodbStore.setMinEditEpoch).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + } + }); +}); + +describe("share-context", () => { + it("GET is open and reports existence + isPublished", async () => { + // No auth headers at all — the handler takes no auth deps and reads nothing from req.body. + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: true, isPublished: true }) }, + }; + const r = res(); + await createShareContextHandler(shareDeps)({ params: { documentId: "doc-1" } } as any, r); + expect(shareDeps.mongodbStore.getShareContext).toHaveBeenCalledWith("doc-1"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ exists: true, isPublished: true }); + }); + + it("GET returns exists:false for unknown doc", async () => { + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: false, isPublished: false }) }, + }; + const r = res(); + await createShareContextHandler(shareDeps)({ params: { documentId: "doc-unknown" } } as any, r); + expect(shareDeps.mongodbStore.getShareContext).toHaveBeenCalledWith("doc-unknown"); + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ exists: false, isPublished: false }); + }); + + it("GET with ?sessionDid reports sessionExists via the same lookup owner-ops use", async () => { + const getSession = vi.fn().mockResolvedValueOnce({ sessionDid: "did-live" }).mockResolvedValueOnce(undefined); + const shareDeps: any = { + mongodbStore: { getShareContext: vi.fn().mockResolvedValue({ exists: true, isPublished: false }) }, + sessionManager: { getSession }, + }; + const r1 = res(); + await createShareContextHandler(shareDeps)( + { params: { documentId: "doc-1" }, query: { sessionDid: "did-live" } } as any, r1 + ); + expect(getSession).toHaveBeenCalledWith("doc-1", "did-live"); + expect(r1.json).toHaveBeenCalledWith({ exists: true, isPublished: false, sessionExists: true }); + + const r2 = res(); + await createShareContextHandler(shareDeps)( + { params: { documentId: "doc-1" }, query: { sessionDid: "did-stale" } } as any, r2 + ); + expect(r2.json).toHaveBeenCalledWith({ exists: true, isPublished: false, sessionExists: false }); }); }); diff --git a/src/tests/services/rotate-route.test.ts b/src/tests/services/rotate-route.test.ts new file mode 100644 index 0000000..bf7c851 --- /dev/null +++ b/src/tests/services/rotate-route.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRotateSessionHandler } from "../../services/rotate-route"; +import { getRoomName } from "../../services/socket-handlers"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +describe("POST /documents/:id/rotate-session", () => { + const documentId = "doc-1"; + const oldSessionDid = "s-old"; + const newSessionDid = "s-new"; + const oldRoom = getRoomName(documentId, oldSessionDid); + + let deps: any, io: any, ownerSock: any, editorSockA: any, editorSockB: any; + + beforeEach(() => { + vi.clearAllMocks(); + ownerSock = { id: "sock-owner", data: { role: "owner" } }; + editorSockA = { id: "sock-a", data: { role: "editor", rail: "public" } }; + editorSockB = { id: "sock-b", data: { role: "editor", rail: "workspace" } }; + + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { + getSession: vi.fn(), + createSession: vi.fn(), + }, + mongodbStore: { setMinEditEpoch: vi.fn() }, + rotationCoordinator: { + isActive: vi.fn().mockReturnValue(false), + begin: vi.fn(), + }, + terminateOldSession: vi.fn().mockResolvedValue(undefined), + }; + + io = { + to: vi.fn(() => ({ emit: vi.fn() })), + in: vi.fn(() => ({ + fetchSockets: vi.fn().mockResolvedValue([ownerSock, editorSockA, editorSockB]), + })), + }; + }); + + function body(overrides: Record = {}) { + return { + oldSessionDid, + newSessionDid, + payload: "opaque-ct", + gateEpoch: 5, + identityToken: "it", + ownerToken: "ot", + ownerAddress: "0xO", + portalAddress: "0xP", + ...overrides, + }; + } + + it("403s when verifyOwnerOp rejects", async () => { + deps.sessionManager.getSession.mockResolvedValue({ + sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", + }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + }); + + it("409s when a rotation is already active for the doc — no broadcast or createSession", async () => { + deps.rotationCoordinator.isActive.mockReturnValue(true); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(409); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + expect(io.to).not.toHaveBeenCalled(); + expect(deps.rotationCoordinator.begin).not.toHaveBeenCalled(); + }); + + it("404s when the old session is not found", async () => { + deps.sessionManager.getSession.mockResolvedValue(undefined); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(404); + expect(deps.authService.verifyOwnerOp).not.toHaveBeenCalled(); + }); + + it("400s when required fields are missing", async () => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: { oldSessionDid } } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it("400s when oldSessionDid is missing", async () => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body({ oldSessionDid: undefined }) } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it.each([NaN, -1, 1.5])("400s when gateEpoch is invalid (%s)", async (badEpoch) => { + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body({ gateEpoch: badEpoch }) } as any, r + ); + + expect(r.status).toHaveBeenCalledWith(400); + expect(deps.sessionManager.getSession).not.toHaveBeenCalled(); + }); + + it("on success: creates the new session, stamps minEditEpoch, broadcasts epoch_available, begins the barrier with non-owner sockets, and responds 200", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, + sessionDid: oldSessionDid, + ownerDid: "od", + ownerIdentityDid: "oid", + portalAddress: "0xPortal", + collabJoinEnabled: true, + roomInfo: "room-info-blob", + appType: "ddoc", + }); + } + return Promise.resolve(undefined); // newSessionDid not created yet + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const emitSpy = vi.fn(); + io.to = vi.fn(() => ({ emit: emitSpy })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.sessionManager.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + documentId, + sessionDid: newSessionDid, + ownerDid: "od", + ownerIdentityDid: "oid", + portalAddress: "0xPortal", + collabJoinEnabled: true, + roomInfo: "room-info-blob", + appType: "ddoc", + }) + ); + + expect(deps.mongodbStore.setMinEditEpoch).toHaveBeenCalledWith(documentId, 5); + + expect(io.to).toHaveBeenCalledWith(oldRoom); + expect(emitSpy).toHaveBeenCalledWith("/session/epoch_available", { + roomId: documentId, + epoch: 5, + payload: "opaque-ct", + }); + + expect(deps.rotationCoordinator.begin).toHaveBeenCalledWith( + expect.objectContaining({ + documentId, + oldSessionDid, + epoch: 5, + expected: ["sock-a", "sock-b"], + }) + ); + + expect(r.status).toHaveBeenCalledWith(200); + expect(r.json).toHaveBeenCalledWith({ ok: true, newSessionDid, liveEditors: 2 }); + }); + + it("excludes a role-capped socket with no rail (e.g. the owner's headless joinOnly read) from expected/liveEditors", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", appType: "ddoc", + }); + } + return Promise.resolve(undefined); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const railessSock: any = { id: "sock-railess", data: { role: "editor" } }; // no rail — not an admitted editor + io.in = vi.fn(() => ({ + fetchSockets: vi.fn().mockResolvedValue([ownerSock, editorSockA, railessSock]), + })); + io.to = vi.fn(() => ({ emit: vi.fn() })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.rotationCoordinator.begin).toHaveBeenCalledWith( + expect.objectContaining({ expected: ["sock-a"] }) + ); + expect(r.json).toHaveBeenCalledWith({ ok: true, newSessionDid, liveEditors: 1 }); + }); + + it("does not recreate the new session row if it already exists (idempotent on retry)", async () => { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", + }); + } + return Promise.resolve({ documentId: docId, sessionDid: newSessionDid, ownerDid: "od" }); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + expect(deps.sessionManager.createSession).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("onCutover broadcasts /session/cutover then schedules terminateOldSession after the drain window", async () => { + vi.useFakeTimers(); + try { + deps.sessionManager.getSession.mockImplementation((docId: string, sessionDid: string) => { + if (sessionDid === oldSessionDid) { + return Promise.resolve({ + documentId: docId, sessionDid: oldSessionDid, ownerDid: "od", ownerIdentityDid: "oid", appType: "ddoc", + }); + } + return Promise.resolve(undefined); + }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const emitSpy = vi.fn(); + io.to = vi.fn(() => ({ emit: emitSpy })); + const r = res(); + + await createRotateSessionHandler(deps, io)( + { params: { documentId }, body: body() } as any, r + ); + + const onCutover = deps.rotationCoordinator.begin.mock.calls[0][0].onCutover; + expect(deps.terminateOldSession).not.toHaveBeenCalled(); + + onCutover(); + + expect(emitSpy).toHaveBeenCalledWith("/session/cutover", { roomId: documentId, epoch: 5 }); + expect(deps.terminateOldSession).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(deps.terminateOldSession).toHaveBeenCalledWith(documentId, oldSessionDid, "ddoc"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/tests/services/rotation-coordinator.test.ts b/src/tests/services/rotation-coordinator.test.ts new file mode 100644 index 0000000..7040d22 --- /dev/null +++ b/src/tests/services/rotation-coordinator.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { RotationCoordinator } from "../../services/rotation-coordinator"; + +describe("RotationCoordinator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires cutover once all expected sockets ack, before the timeout", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a", "b"], onCutover }); + c.recordAck("d", "a"); + expect(onCutover).not.toHaveBeenCalled(); + c.recordAck("d", "b"); + expect(onCutover).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(5000); + expect(onCutover).toHaveBeenCalledTimes(1); // not fired again by the timer + }); + + it("fires cutover on timeout even with a missing ack", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a", "b"], onCutover }); + c.recordAck("d", "a"); + vi.advanceTimersByTime(4000); + expect(onCutover).toHaveBeenCalledTimes(1); + }); + + it("with no expected sockets, cuts over immediately", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: [], onCutover }); + expect(onCutover).toHaveBeenCalledTimes(1); + }); + + it("ignores acks for an unknown or finished doc", () => { + const c = new RotationCoordinator(4000); + const onCutover = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a"], onCutover }); + c.finish("d"); + c.recordAck("d", "a"); + expect(onCutover).not.toHaveBeenCalled(); + }); + + it("isActive is true only while an unfired barrier exists", () => { + const c = new RotationCoordinator(4000); + expect(c.isActive("d")).toBe(false); + c.begin({ documentId: "d", oldSessionDid: "s", epoch: 2, expected: ["a"], onCutover: vi.fn() }); + expect(c.isActive("d")).toBe(true); + c.recordAck("d", "a"); // fires + expect(c.isActive("d")).toBe(false); + }); + + it("a fired barrier does not leak: begin/fire works cleanly across two consecutive rotations for the same doc", () => { + const c = new RotationCoordinator(4000); + const onCutover1 = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s1", epoch: 1, expected: ["a"], onCutover: onCutover1 }); + c.recordAck("d", "a"); // fires via ack-complete + expect(onCutover1).toHaveBeenCalledTimes(1); + expect(c.isActive("d")).toBe(false); + + const onCutover2 = vi.fn(); + c.begin({ documentId: "d", oldSessionDid: "s2", epoch: 2, expected: ["b"], onCutover: onCutover2 }); + expect(c.isActive("d")).toBe(true); + vi.advanceTimersByTime(4000); // fires via timeout + expect(onCutover2).toHaveBeenCalledTimes(1); + expect(onCutover1).toHaveBeenCalledTimes(1); // unaffected + expect(c.isActive("d")).toBe(false); + + // A late ack against the now-finished second barrier is a no-op. + c.recordAck("d", "b"); + expect(onCutover2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index 332b041..1dce662 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -8,6 +8,7 @@ vi.mock("../../database/models", () => ({ findOneAndUpdate: vi.fn(), find: vi.fn(), countDocuments: vi.fn(), + updateOne: vi.fn(), }, DocumentUpdateModel: { deleteMany: vi.fn(), @@ -200,13 +201,14 @@ describe("SessionManager", () => { const sessions = await sessionManager.getOtherNonTerminatedSessions( "doc-1", "owner-did", + null, "session-1" ); expect(SessionModel.find).toHaveBeenCalledWith({ documentId: "doc-1", - ownerDid: "owner-did", state: { $ne: "terminated" }, + $or: [{ ownerDid: "owner-did" }], sessionDid: { $ne: "session-1" }, }); expect(sessions).toEqual([ @@ -217,11 +219,11 @@ describe("SessionManager", () => { // getNonTerminatedSessionsForDocument - it("returns every non-terminated sessionDid for a document regardless of owner", async () => { + it("returns every non-terminated sessionDid (with appType) for a document regardless of owner", async () => { vi.mocked(SessionModel.find).mockReturnValue({ lean: vi.fn().mockResolvedValue([ - { sessionDid: "s-1" }, - { sessionDid: "s-2" }, + { sessionDid: "s-1", appType: "ddoc" }, + { sessionDid: "s-2", appType: "dsheet" }, ]), } as any); @@ -229,9 +231,12 @@ describe("SessionManager", () => { expect(SessionModel.find).toHaveBeenCalledWith( { documentId: "doc-1", state: { $ne: "terminated" } }, - { sessionDid: 1 } + { sessionDid: 1, appType: 1 } ); - expect(sessions).toEqual([{ sessionDid: "s-1" }, { sessionDid: "s-2" }]); + expect(sessions).toEqual([ + { sessionDid: "s-1", appType: "ddoc" }, + { sessionDid: "s-2", appType: "dsheet" }, + ]); }); // getActiveSessionsCount @@ -384,4 +389,95 @@ describe("SessionManager", () => { await freshManager.destroy(); }); + + // updateSessionOwnerDid + + describe("updateSessionOwnerDid", () => { + it("persists the new ownerDid and mirrors it in memory", async () => { + const freshManager = new SessionManager(); + await freshManager.createSession({ + documentId: "doc1", + sessionDid: "sess1", + ownerDid: "did:key:old", + portalAddress: "0x" + "a".repeat(40), + collabJoinEnabled: false, + } as any); + vi.clearAllMocks(); + + await freshManager.updateSessionOwnerDid("doc1", "sess1", "did:key:new"); + + expect(SessionModel.updateOne).toHaveBeenCalledWith( + { documentId: "doc1", sessionDid: "sess1" }, + { $set: { ownerDid: "did:key:new" } } + ); + const session = await freshManager.getSession("doc1", "sess1"); + expect(session?.ownerDid).toBe("did:key:new"); + + await freshManager.destroy(); + }); + }); + + // getNonTerminatedSessionsForPortal + + describe("getNonTerminatedSessionsForPortal", () => { + it("matches portalAddress case-insensitively and excludes terminated", async () => { + const portal = "0x" + "A".repeat(40); + vi.mocked(SessionModel.find).mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ documentId: "doc-1", sessionDid: "sess-1" }]), + } as any); + + const sessions = await sessionManager.getNonTerminatedSessionsForPortal(portal); + + expect(SessionModel.find).toHaveBeenCalledWith( + { + portalAddress: { $regex: `^${portal}$`, $options: "i" }, + state: { $ne: "terminated" }, + }, + { documentId: 1, sessionDid: 1 } + ); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "sess-1" }]); + }); + + it("returns [] for a non-hex portalAddress without querying", async () => { + const out = await sessionManager.getNonTerminatedSessionsForPortal("not-an-address');--"); + + expect(out).toEqual([]); + expect(SessionModel.find).not.toHaveBeenCalled(); + }); + }); + + // getOtherNonTerminatedSessions ($or sweep) + + describe("getOtherNonTerminatedSessions ($or sweep)", () => { + it("matches by portalAddress even when ownerDid differs (post-rotation orphan)", async () => { + const portal = "0x" + "a".repeat(40); + vi.mocked(SessionModel.find).mockResolvedValue([ + { documentId: "doc-1", sessionDid: "orphan-session", ownerDid: "did:key:old", appType: "ddoc" }, + ] as any); + + const sessions = await sessionManager.getOtherNonTerminatedSessions("doc-1", "did:key:new", portal); + + expect(SessionModel.find).toHaveBeenCalledWith({ + documentId: "doc-1", + state: { $ne: "terminated" }, + $or: [{ ownerDid: "did:key:new" }, { portalAddress: { $regex: `^${portal}$`, $options: "i" } }], + }); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "orphan-session", appType: "ddoc" }]); + }); + + it("still matches legacy null-portal sessions by ownerDid", async () => { + vi.mocked(SessionModel.find).mockResolvedValue([ + { documentId: "doc-1", sessionDid: "legacy-session", ownerDid: "did:key:x", appType: "ddoc" }, + ] as any); + + const sessions = await sessionManager.getOtherNonTerminatedSessions("doc-1", "did:key:x", null); + + expect(SessionModel.find).toHaveBeenCalledWith({ + documentId: "doc-1", + state: { $ne: "terminated" }, + $or: [{ ownerDid: "did:key:x" }], + }); + expect(sessions).toEqual([{ documentId: "doc-1", sessionDid: "legacy-session", appType: "ddoc" }]); + }); + }); }); diff --git a/src/tests/services/socket-handlers.epochLoaded.test.ts b/src/tests/services/socket-handlers.epochLoaded.test.ts new file mode 100644 index 0000000..22a357c --- /dev/null +++ b/src/tests/services/socket-handlers.epochLoaded.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleEpochLoaded } from "../../services/socket-handlers"; +import { rotationCoordinator } from "../../services/rotation-coordinator"; +import { ErrorCode } from "../../types/index"; +import type { AppSocket, SocketData } from "../../types/index"; + +vi.mock("../../services/rotation-coordinator", () => ({ + rotationCoordinator: { recordAck: vi.fn() }, +})); + +const defaultSocketData: SocketData = { + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "editor", + authenticated: true, + appType: "ddoc", +}; + +function createFakeSocket(dataOverrides?: Partial): AppSocket { + const data: SocketData = { ...defaultSocketData, ...dataOverrides }; + return { + id: "socket-1", + data, + } as unknown as AppSocket; +} + +describe("handleEpochLoaded", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("records the ack and acks { ok: true } for an authenticated socket", () => { + const socket = createFakeSocket({ + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + }); + const callback = vi.fn(); + const args = { documentId: "test-document-id", epoch: 3 }; + + handleEpochLoaded(socket, args, callback); + + expect(rotationCoordinator.recordAck).toHaveBeenCalledWith("test-document-id", "socket-1"); + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { ok: true }, + }); + }); + + it("rejects an unauthenticated socket with NOT_AUTHENTICATED and never records an ack", () => { + const socket = createFakeSocket({ authenticated: false }); + const callback = vi.fn(); + const args = { documentId: "test-document-id", epoch: 3 }; + + handleEpochLoaded(socket, args, callback); + + expect(rotationCoordinator.recordAck).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + }); +}); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index ad2d439..5873552 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -47,24 +47,40 @@ describe("handleAuth", () => { verifyOwnerToken: vi.fn(), verifyCollaborationToken: vi.fn(), verifyIdentityToken: vi.fn(), + verifyEditUcan: vi.fn(), getServerDid: vi.fn(), }; const fakeSessionManager = { getSession: vi.fn(), + getSessionIncludingTerminated: vi.fn(), getOtherNonTerminatedSessions: vi.fn(), terminateSession: vi.fn(), createSession: vi.fn(), updateRoomInfo: vi.fn(), addClientToSession: vi.fn(), + removeClientFromSession: vi.fn(), getCollabJoinEnabled: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), fillOwnerIdentityDidIfAbsent: vi.fn(), + updateSessionOwnerDid: vi.fn(), }; - const fakeMongoDBStore = {} as any; + const fakeMongoDBStore = { + getDocumentMeta: vi.fn().mockResolvedValue(null), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + // Echoes the requested portal back, so unrelated owner-bind tests pass regardless + // of their contractAddress. Mismatch tests override with mockResolvedValueOnce. + pinDocumentPortalIfAbsent: vi + .fn() + .mockImplementation(async (p: { portalAddress: string }) => ({ + portalAddress: p.portalAddress, + })), + } as any; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + editBoundCache: { check: vi.fn() } as any, }; beforeEach(() => { @@ -143,7 +159,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -169,6 +184,7 @@ describe("handleAuth", () => { expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", + fakeArgs.contractAddress, fakeArgs.sessionDid, ); expect(fakeSessionManager.createSession).toHaveBeenCalledWith({ @@ -211,10 +227,180 @@ describe("handleAuth", () => { role: "owner", sessionType: "new", roomInfo: fakeArgs.roomInfo, + title: null, }, }); }); + describe("owner session portal pin (C1)", () => { + const pinArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + roomInfo: "room-info", + }; + + function ownerBindHappyPath() { + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeSessionManager.createSession.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + } + + it("(a) pins the portal on the first owner bind of an unpinned doc and creates the session", async () => { + ownerBindHappyPath(); + // Default mock echoes the requested portal back (first-writer-wins with no prior pin). + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeMongoDBStore.pinDocumentPortalIfAbsent).toHaveBeenCalledWith({ + documentId: pinArgs.documentId, + portalAddress: pinArgs.contractAddress, + ownerDid: "owner-did", + ownerIdentityDid: "owner-identity-did", + sessionDid: pinArgs.sessionDid, + }); + expect(fakeSessionManager.createSession).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("(b) succeeds when the bind's portal equals the existing pin", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: pinArgs.contractAddress, + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeSessionManager.createSession).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + + it("(c) rejects 403/AUTH_TOKEN_INVALID when the bind's portal differs from the pin, without creating a session", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: "0x9999999999999999999999999999999999999999", + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 403, + error: "Document is owned by a different portal", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + }); + + it("(d) the mismatch reject precedes the terminate-other-sessions loop", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: "0x9999999999999999999999999999999999999999", + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(fakeSessionManager.getOtherNonTerminatedSessions).not.toHaveBeenCalled(); + expect(fakeSessionManager.terminateSession).not.toHaveBeenCalled(); + }); + + it("(e) rejects 403/AUTH_TOKEN_INVALID when the pin read fails (portalAddress: null), without creating a session", async () => { + ownerBindHappyPath(); + fakeMongoDBStore.pinDocumentPortalIfAbsent.mockResolvedValueOnce({ + portalAddress: null, + }); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), pinArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 403, + error: "Document is owned by a different portal", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + }); + }); + + it("returns the latest stored DocumentMeta title in the ack", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeMongoDBStore.getDocumentMeta.mockResolvedValueOnce({ editLock: null, title: "enc-title" }); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeMongoDBStore.getDocumentMeta).toHaveBeenCalledWith("doc-1"); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + data: expect.objectContaining({ title: "enc-title" }), + }) + ); + }); + + it("returns a null ack title when the DocumentMeta read fails", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + identityToken: "identity-token", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("owner-identity-did"); + fakeSessionManager.getOtherNonTerminatedSessions.mockResolvedValue([]); + fakeMongoDBStore.getDocumentMeta.mockRejectedValueOnce(new Error("mongo down")); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 200, + data: expect.objectContaining({ title: null }), + }) + ); + }); + it("creates a new owner session and terminates other active sessions when they exist", async () => { const fakeRoomBroadcastOperator = { emit: vi.fn() }; const fetchSockets = vi.fn(); @@ -230,7 +416,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", roomInfo: "room-info", }; const callback = vi.fn(); @@ -274,6 +459,7 @@ describe("handleAuth", () => { expect(fakeSessionManager.getOtherNonTerminatedSessions).toHaveBeenCalledWith( fakeArgs.documentId, "owner-did", + fakeArgs.contractAddress, fakeArgs.sessionDid, ); @@ -351,6 +537,7 @@ describe("handleAuth", () => { role: "owner", sessionType: "new", roomInfo: fakeArgs.roomInfo, + title: null, }, }); }); @@ -419,6 +606,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(existingSession); fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); @@ -444,10 +632,76 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); + it("rotationCutover: silently migrates room, skips user_joined, and cleans up the old session's client list", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + // Already-authed socket: socket.data.sessionDid holds the PRE-rotation sessionDid. + const fakeSocket = createFakeSocket(fakeBroadcastOperator, { + authenticated: true, + documentId: "doc-1", + sessionDid: "session-1-old", + role: "editor", + }); + (fakeSocket as any).leave = vi.fn(); + + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1-new", + collaborationToken: "collab-token", + rotationCutover: true, + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const oldRoomName = getRoomName(fakeArgs.documentId, "session-1-old"); + const newRoomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + + // Silent leave of the prior room — no /room/membership_change for it. + expect((fakeSocket as any).leave).toHaveBeenCalledWith(oldRoomName); + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledWith( + fakeArgs.documentId, + "session-1-old", + fakeSocket.id + ); + + // Normal admission into the new room is unaffected. + expect(fakeSocket.join).toHaveBeenCalledWith(newRoomName); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(fakeSocket.data.sessionDid).toBe(fakeArgs.sessionDid); + + // No membership blip on the rotation cutover join. + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalledWith( + "/room/membership_change", + expect.objectContaining({ action: "user_joined" }) + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, statusCode: 200 }) + ); + }); + it("returns 404 when existing session is not found and no ownerToken is provided", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); @@ -688,6 +942,7 @@ describe("handleAuth", () => { role: "owner", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -787,6 +1042,7 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -876,6 +1132,7 @@ describe("handleAuth", () => { fakeSessionManager.getSession.mockResolvedValue(existingSession); fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); @@ -888,6 +1145,7 @@ describe("handleAuth", () => { role: "editor", sessionType: "existing", roomInfo: existingSession.roomInfo, + title: null, }, }); }); @@ -948,7 +1206,7 @@ describe("handleAuth", () => { ownerToken: "owner-token", ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", - // no identityToken / identityContractAddress + // no identityToken }; const callback = vi.fn(); @@ -978,7 +1236,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "bad-identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", }; const callback = vi.fn(); @@ -988,11 +1245,7 @@ describe("handleAuth", () => { await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); - expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( - "bad-identity-token", - "0x0000000000000000000000000000000000000003", - "doc-1" - ); + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith("bad-identity-token", "doc-1"); expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith({ status: false, @@ -1013,7 +1266,6 @@ describe("handleAuth", () => { ownerAddress: "0x0000000000000000000000000000000000000001", contractAddress: "0x0000000000000000000000000000000000000002", identityToken: "identity-token", - identityContractAddress: "0x0000000000000000000000000000000000000003", }; const callback = vi.fn(); @@ -1034,11 +1286,7 @@ describe("handleAuth", () => { await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); - expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith( - "identity-token", - "0x0000000000000000000000000000000000000003", - "doc-1" - ); + expect(fakeAuthService.verifyIdentityToken).toHaveBeenCalledWith("identity-token", "doc-1"); expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).toHaveBeenCalledWith( "doc-1", "session-1", @@ -1046,5 +1294,588 @@ describe("handleAuth", () => { ); expect(fakeSocket.data.role).toBe("owner"); }); + + it("joinOnly: rejects ROOM_NOT_ESTABLISHED for a session that never existed, even after the terminated-inclusive retry", async () => { + fakeSessionManager.getSession.mockResolvedValue(null); + fakeSessionManager.getSessionIncludingTerminated.mockResolvedValue(undefined); + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + joinOnly: true, + }, callback); + + expect(fakeSessionManager.getSessionIncludingTerminated).toHaveBeenCalledWith("doc-1", "session-1"); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 404, + error: "Room not established", + errorCode: ErrorCode.ROOM_NOT_ESTABLISHED, + }); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + }); + + describe("owner-verified joinOnly read admission", () => { + const privateSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: "did:key:creator", + appType: "ddoc", + }; + const verifiedOwnerJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "identity-token", + joinOnly: true, + }; + + it("1. admits a verified owner into an EXISTING private session with no gp/workspace/public grants", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...verifiedOwnerJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + // Bypass is admission-only: no gp/workspace/public grant was computed for this socket. + expect(fakeSocket.data.rail).toBeUndefined(); + expect(fakeSocket.data.railKind).toBeUndefined(); + }); + + it("2. admits a verified owner into a TERMINATED session via the terminated-inclusive lookup", async () => { + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeSessionManager.getSessionIncludingTerminated.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...verifiedOwnerJoinArgs }, callback); + + expect(fakeSessionManager.getSessionIncludingTerminated).toHaveBeenCalledWith("doc-1", "session-1"); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + }); + + it("3. still rejects a NON-owner (no grants) with JOIN_DISABLED — the gate still applies", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...privateSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + // No ownerToken presented: ownerDid never resolves, so role resolves to "editor" + // pre-cap too — this bearer was never owner-shaped in the first place. + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + joinOnly: true, + }, + callback + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + }); + + it("joinOnly: caps role at editor and skips the null-fill heal on an unbound session", async () => { + fakeSessionManager.getSession.mockResolvedValue({ + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: null, + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "member-identity-token", + joinOnly: true, + }, callback); + + expect(fakeSessionManager.fillOwnerIdentityDidIfAbsent).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + data: expect.objectContaining({ role: "editor" }), + }) + ); + // This bearer resolved to "owner" pre-cap too (unbound session, shared-DID compare) — + // the same ambiguous match already gets full owner rights via the plain (non-joinOnly) + // path, so the rail gate is bypassed here rather than consulted. + expect(fakeSocket.data.rail).toBeUndefined(); + expect(fakeSessionManager.getWorkspaceEditEnabled).not.toHaveBeenCalled(); + }); + + const boundSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:shared-portal", + ownerIdentityDid: "did:key:creator", + appType: "ddoc", + }; + const boundJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "identity-token", + }; + + it("role: proven identity == bound + matching ownerToken → owner", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + ); + }); + + it("role: proven identity differs from bound → editor (workspace member)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) + ); + }); + + it("role: INVALID identity token on a bound session → 401, never a fallback", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue(null); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Invalid identity proof", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("role: token-less join on a bound session → editor (owner needs a proven identity token)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const { identityToken, ...tokenless } = boundJoinArgs; + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), tokenless as any, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "editor" }) }) + ); + }); + + it("workspace arm: rejects a FOREIGN portal's valid ownerToken (no cross-portal admission)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:OTHER-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:stranger"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("workspace arm: member is rejected fail-closed when the flag is undefined", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...boundJoinArgs }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("workspace arm: member with matching ownerDid + flag true → rail and railKind workspace", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(fakeSocket.data.rail).toBe("workspace"); + expect(fakeSocket.data.railKind).toBe("workspace"); + }); + + describe("rotation heal", () => { + const rotatedSession = { + documentId: "doc-1", + sessionDid: "session-1", + ownerDid: "did:key:old", + ownerIdentityDid: "did:key:creator", + portalAddress: "0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa", + appType: "ddoc", + }; + const rotatedJoinArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "shared-workspace-owner-token", + ownerAddress: "0x1111111111111111111111111111111111111111", + // Same portal as rotatedSession.portalAddress, different case. + contractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + joinOnly: true, + }; + + it("adopts a rotated ownerDid for the session's own portal and admits the workspace member", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...rotatedJoinArgs }, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + "doc-1", + "session-1", + "did:key:new" + ); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + // Post-heal, ownerDid matches. A token-less join on a bound session is capped to + // "editor" (no legacy shared-DID owner), so the workspace member is admitted via the + // workspace rail (tier = edit) rather than the owner-shaped joinOnly bypass. + expect(fakeSocket.data.rail).toBe("workspace"); + }); + + it("does NOT heal when the presented contractAddress differs from session.portalAddress", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + ...rotatedJoinArgs, + contractAddress: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + callback + ); + + expect(fakeSessionManager.updateSessionOwnerDid).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("does NOT heal when portalAddress is null (legacy session)", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession, portalAddress: null }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), createFakeSocket(), { ...rotatedJoinArgs }, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).not.toHaveBeenCalled(); + }); + + it("restores owner role for the identity-bound creator after rotation", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...rotatedSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:creator"); + const callback = vi.fn(); + + await handleAuth( + deps, + createFakeIO(), + createFakeSocket(), + { + ...rotatedJoinArgs, + joinOnly: false, + identityToken: "identity-token", + }, + callback + ); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + "doc-1", + "session-1", + "did:key:new" + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ status: true, data: expect.objectContaining({ role: "owner" }) }) + ); + }); + }); + + describe("actorIdentityDid stamp", () => { + it("stamps the proven identity DID on the socket for a member join", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + fakeAuthService.verifyIdentityToken.mockResolvedValue("did:key:member"); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, { ...boundJoinArgs }, callback); + + expect(fakeSocket.data.actorIdentityDid).toBe("did:key:member"); + }); + + it("leaves actorIdentityDid undefined when no identityToken is presented", async () => { + fakeSessionManager.getSession.mockResolvedValue({ ...boundSession }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("did:key:collab"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:shared-portal"); + const { identityToken, ...tokenless } = boundJoinArgs; + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + await handleAuth(deps, createFakeIO(), fakeSocket, tokenless as any, callback); + + expect(fakeSocket.data.actorIdentityDid).toBeUndefined(); + }); + }); +}); + +describe("handleAuth — edit-claim admission (existing session, non-owner)", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn(), + verifyCollaborationToken: vi.fn(), + verifyIdentityToken: vi.fn(), + verifyEditUcan: vi.fn(), + getServerDid: vi.fn(), + }; + const fakeSessionManager = { + getSession: vi.fn(), + getOtherNonTerminatedSessions: vi.fn(), + terminateSession: vi.fn(), + createSession: vi.fn(), + updateRoomInfo: vi.fn(), + addClientToSession: vi.fn(), + getCollabJoinEnabled: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), + fillOwnerIdentityDidIfAbsent: vi.fn(), + }; + const fakeMongoDBStore = { + getDocumentMeta: vi.fn().mockResolvedValue(null), + getMinEditEpoch: vi.fn().mockResolvedValue(0), + } as any; + + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore, + editBoundCache: { check: vi.fn() } as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseArgs = (over: Partial = {}): AuthArgs => ({ + documentId: "doc-1", + sessionDid: "sess-1", + collaborationToken: "ct", + appType: "ddoc", + ...over, + }); + + function existingSessionSetup() { + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "sess-1", + ownerDid: "owner-did", + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(true); + } + + it("rejects an editUcan that verifies to null (epoch-fact UCANs retired)", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue(null); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "epoch-fact" }), cb); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + }); + + it("REJECTS an invalid editUcan (verifyEditUcan → null) without falling through", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue(null); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "forged" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); + expect(socket.data.rail).toBeUndefined(); + }); + + it("admits a workspace editor (member proof + tier enabled) with rail=workspace", async () => { + // The shared portal secret (ownerToken) only proves portal membership; the per-person + // identity proof decides owner-vs-editor. Here identity resolves a non-creator DID, so + // this bearer is a workspace editor even though ownerToken matches the session's own ownerDid. + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: "sess-1", + ownerDid: "owner-did", + ownerIdentityDid: "owner-identity-did", + appType: "ddoc", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(true); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); // session's own ownerDid + fakeAuthService.verifyIdentityToken.mockResolvedValue("member-identity-did"); // != bound owner identity ⇒ editor + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth( + deps, + createFakeIO(), + socket, + baseArgs({ + ownerToken: "ot", + ownerAddress: "0x1111111111111111111111111111111111111111", + contractAddress: "0x2222222222222222222222222222222222222222", + identityToken: "it", + }), + cb + ); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("workspace"); + expect(socket.data.railKind).toBe("workspace"); + }); + + describe("epoch floor (minEditEpoch)", () => { + it("rejects a gp-actor editUcan whose epoch is below the doc's minEditEpoch floor", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 1 }); + fakeMongoDBStore.getMinEditEpoch.mockResolvedValue(2); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gp-token" }), cb); + expect(fakeMongoDBStore.getMinEditEpoch).toHaveBeenCalledWith("doc-1"); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.JOIN_DISABLED }) + ); + expect(deps.editBoundCache.check).not.toHaveBeenCalled(); + expect(socket.data.rail).toBeUndefined(); + }); + + it("admits a gp-actor editUcan whose epoch meets the doc's minEditEpoch floor", async () => { + existingSessionSetup(); + fakeAuthService.verifyEditUcan.mockResolvedValue({ kind: "actor", editHandle: "h1", epoch: 2 }); + fakeMongoDBStore.getMinEditEpoch.mockResolvedValue(2); + (deps.editBoundCache.check as ReturnType).mockResolvedValue("bound"); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs({ editUcan: "gp-token" }), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("gp"); + expect(socket.data.railKind).toBe("gp-actor"); + expect(socket.data.actorHandle).toBe("h1"); + }); + }); + + it("public bearer is admitted as rail=public ONLY on collabJoinEnabled === true", async () => { + existingSessionSetup(); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs(), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + expect(socket.data.rail).toBe("public"); + }); + + it("REJECTS a public bearer when collabJoinEnabled === undefined (legacy hardening — was open)", async () => { + existingSessionSetup(); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(undefined); + const socket = createFakeSocket(); + const cb = vi.fn(); + await handleAuth(deps, createFakeIO(), socket, baseArgs(), cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, errorCode: ErrorCode.JOIN_DISABLED })); + expect(socket.data.rail).toBeUndefined(); + }); }); diff --git a/src/tests/services/socket-handlers.handleAwareness.test.ts b/src/tests/services/socket-handlers.handleAwareness.test.ts index f4c325e..a1965c7 100644 --- a/src/tests/services/socket-handlers.handleAwareness.test.ts +++ b/src/tests/services/socket-handlers.handleAwareness.test.ts @@ -26,9 +26,24 @@ function createFakeSocket( id: "socket-1", data, to: vi.fn(() => op), + disconnect: vi.fn(), } as unknown as AppSocket; } +function createDeps(check = vi.fn().mockResolvedValue("bound")) { + return { + authService: {} as any, + sessionManager: { + getWorkspaceEditEnabled: vi.fn().mockResolvedValue(true), + getCollabJoinEnabled: vi.fn().mockResolvedValue(true), + } as any, + mongodbStore: {} as any, + editBoundCache: { check } as any, + }; +} + +const flush = () => new Promise((r) => setImmediate(r)); + describe('handleAwareness', () => { beforeEach(() => { vi.clearAllMocks(); @@ -43,7 +58,7 @@ describe('handleAwareness', () => { collaborationToken: "", }; - await handleAwareness(fakeIO, fakeSocket, fakeArgs); + await handleAwareness(createDeps() as any, fakeIO, fakeSocket, fakeArgs); expect(fakeSocket.to).not.toHaveBeenCalled(); }); @@ -59,7 +74,7 @@ describe('handleAwareness', () => { collaborationToken: "test-collaborator-token", }; - await handleAwareness(fakeIO, fakeSocket, fakeArgs); + await handleAwareness(createDeps() as any, fakeIO, fakeSocket, fakeArgs); const roomName = `session::${fakeArgs.documentId}__${fakeSocket.data.sessionDid}`; expect(fakeSocket.to).toHaveBeenCalledWith(roomName); @@ -83,6 +98,43 @@ describe('handleAwareness', () => { collaborationToken: "", }; - await expect(handleAwareness(fakeIO, fakeSocket as any, fakeArgs)).resolves.not.toThrow(); + await expect(handleAwareness(createDeps() as any, fakeIO, fakeSocket as any, fakeArgs)).resolves.not.toThrow(); + }); + + it("disconnects a revoked gp-actor editor on awareness traffic", async () => { + const deps = createDeps(vi.fn().mockResolvedValue("unbound")); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect((socket as any).to).toHaveBeenCalled(); // broadcast always goes out + expect((socket as any).disconnect).toHaveBeenCalledWith(true); + }); + + it("never re-checks the owner", async () => { + const check = vi.fn(); + const deps = createDeps(check); + const socket = createFakeSocket(undefined, { role: "owner" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect(check).not.toHaveBeenCalled(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); + }); + + it("throttles: a second call within the interval does not re-check", async () => { + const check = vi.fn().mockResolvedValue("bound"); + const deps = createDeps(check); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("leaves an admitted editor connected", async () => { + const deps = createDeps(vi.fn().mockResolvedValue("bound")); + const socket = createFakeSocket(undefined, { role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleAwareness(deps as any, createFakeIO(), socket, { documentId: "test-document-id", data: "x" } as any); + await flush(); + expect((socket as any).disconnect).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts index 245037f..7bf5155 100644 --- a/src/tests/services/socket-handlers.handleCommitHistory.test.ts +++ b/src/tests/services/socket-handlers.handleCommitHistory.test.ts @@ -43,6 +43,7 @@ describe("commitHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, + 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 4310886..9c20f8e 100644 --- a/src/tests/services/socket-handlers.handleDisconnecting.test.ts +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -46,6 +46,7 @@ describe("handleDisconnecting", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + 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 735d4ab..a243a53 100644 --- a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts @@ -41,6 +41,7 @@ describe("handleDocumentCommit", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, + 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 5c7b12d..a5deefc 100644 --- a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -3,6 +3,7 @@ import { handleDocumentUpdate, getRoomName } from "../../services/socket-handler import type { AppServer, AppSocket, DocumentUpdateArgs } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; import { ErrorCode } from "../../types"; +import { SessionTerminatedError } from "../../services/mongodb-store"; function createFakeIO(): AppServer { return {} as unknown as AppServer; @@ -30,6 +31,7 @@ function createFakeSocket( id: "socket-1", data, to: vi.fn(() => toReturn), + disconnect: vi.fn(), } as unknown as AppSocket; } @@ -39,6 +41,8 @@ describe("handleDocumentUpdate", () => { }; const fakeSessionManager = { getRuntimeSession: vi.fn(), + getWorkspaceEditEnabled: vi.fn(), + getCollabJoinEnabled: vi.fn(), }; const fakeMongoDBStore = { createUpdate: vi.fn(), @@ -48,6 +52,7 @@ describe("handleDocumentUpdate", () => { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore as any, + editBoundCache: {} as any, }; beforeEach(() => { @@ -285,6 +290,33 @@ describe("handleDocumentUpdate", () => { }); }); + it("acks 409 SESSION_TERMINATED and does not fan out when createUpdate rejects with SessionTerminatedError", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + fakeMongoDBStore.createUpdate.mockRejectedValue(new SessionTerminatedError()); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 409, + error: "Session terminated", + errorCode: ErrorCode.SESSION_TERMINATED, + }); + }); + it("returns 500 when an unexpected error occurs in document update handler", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); @@ -306,5 +338,71 @@ describe("handleDocumentUpdate", () => { errorCode: ErrorCode.INTERNAL_ERROR, }); }); + + describe("handleDocumentUpdate — rail write-guard", () => { + function makeEditor( + railData: { rail: "gp" | "workspace" | "public" }, + broadcast?: { emit: ReturnType } + ) { + const socket = createFakeSocket(broadcast ?? { emit: vi.fn() }, { role: "editor" }); + socket.data.rail = railData.rail; + fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + return socket; + } + const args = { documentId: "doc-1", data: "d", collaborationToken: "ct" } as DocumentUpdateArgs; + + it("403 when a workspace editor's tier is now disabled", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(false); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("403 when a public editor's collabJoinEnabled is no longer true (false OR undefined)", async () => { + for (const val of [false, undefined]) { + vi.clearAllMocks(); + const socket = makeEditor({ rail: "public" }); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(val); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect(fakeMongoDBStore.createUpdate).not.toHaveBeenCalled(); + } + }); + + it("disconnects the socket after the EDIT_REVOKED 403", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(false); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED })); + expect((socket as any).disconnect).toHaveBeenCalledWith(true); + }); + + it("does not disconnect an admitted editor", async () => { + const socket = makeEditor({ rail: "workspace" }); + fakeSessionManager.getWorkspaceEditEnabled.mockResolvedValue(true); + fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect((socket as any).disconnect).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + + it("does NOT guard a dsheet editor (no rail model — must not 403)", async () => { + const socket = createFakeSocket({ emit: vi.fn() }, { role: "editor" }); + socket.data.appType = "dsheet"; // excluded from the ddoc-only guard + fakeSessionManager.getRuntimeSession.mockResolvedValue({ sessionDid: socket.data.sessionDid }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + fakeMongoDBStore.createUpdate.mockResolvedValue({ id: "u1", documentId: "doc-1", data: "d", updateType: "yjs_update", commitCid: null, createdAt: 1 }); + const cb = vi.fn(); + await handleDocumentUpdate(deps, createFakeIO(), socket, args, cb); + expect(fakeMongoDBStore.createUpdate).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + }); }); diff --git a/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts new file mode 100644 index 0000000..e6168e2 --- /dev/null +++ b/src/tests/services/socket-handlers.handleMirrorSnapshot.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleMirrorSnapshot } from "../../services/socket-handlers"; +import { ErrorCode } from "../../types"; + +function socket(data: any) { + return { + id: "s1", + data: { authenticated: true, documentId: "doc-1", sessionDid: "sess-1", ...data }, + disconnect: vi.fn(), + } as any; +} + +describe("handleMirrorSnapshot", () => { + let deps: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + sessionManager: { getRuntimeSession: vi.fn().mockResolvedValue({ sessionDid: "sess-1" }) }, + mongodbStore: { upsertMirrorSnapshot: vi.fn().mockResolvedValue(undefined) }, + editBoundCache: { check: vi.fn() }, + }; + }); + + it("401s when not authenticated", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ authenticated: false, role: "editor", rail: "gp" }), { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401 })); + }); + + it("403s an authenticated socket that is neither owner nor an admitted rail editor", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "editor" }), { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("400s on a missing/invalid fileKeyEpoch", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct" } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400 })); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("400s (no persist) on an out-of-range fileKeyEpoch that would shadow the keep-latest read", async () => { + for (const bad of [1e21, Number.MAX_SAFE_INTEGER, 1_000_001, NaN, Infinity, 1.5]) { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: bad } as any, cb); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400 })); + } + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("persists for the owner", async () => { + const cb = vi.fn(); + await handleMirrorSnapshot(deps, socket({ role: "owner" }), { data: "ct", fileKeyEpoch: 2 } as any, cb); + expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true })); + }); + + it("disconnects the socket after the EDIT_REVOKED 403", async () => { + deps.editBoundCache.check.mockResolvedValue("unbound"); + const cb = vi.fn(); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.EDIT_REVOKED }) + ); + expect((s as any).disconnect).toHaveBeenCalledWith(true); + expect(deps.mongodbStore.upsertMirrorSnapshot).not.toHaveBeenCalled(); + }); + + it("does not disconnect an admitted editor", async () => { + deps.editBoundCache.check.mockResolvedValue("bound"); + const cb = vi.fn(); + const s = socket({ role: "editor", rail: "gp", railKind: "gp-actor", actorHandle: "h1" }); + await handleMirrorSnapshot(deps, s, { data: "ct", fileKeyEpoch: 1 } as any, cb); + expect((s as any).disconnect).not.toHaveBeenCalled(); + expect(deps.mongodbStore.upsertMirrorSnapshot).toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts index ebc7970..ade004c 100644 --- a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -4,8 +4,16 @@ import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; import type { AppSocket } from "../../types"; import { ErrorCode } from "../../types"; -function fakeSocket(role: "owner" | "editor"): AppSocket { - return { id: "s1", data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" } } as unknown as AppSocket; +function fakeSocket( + role: "owner" | "editor", + broadcastOperator?: { emit: ReturnType } +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + return { + id: "s1", + data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" }, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; } describe("handleSetDocumentMeta", () => { @@ -15,6 +23,7 @@ describe("handleSetDocumentMeta", () => { authService: {} as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { upsertDocumentMeta } as any, + editBoundCache: {} as any, }; beforeEach(() => { @@ -23,8 +32,11 @@ describe("handleSetDocumentMeta", () => { it("rejects a non-owner", async () => { const cb = vi.fn(); - await handleSetDocumentMeta(deps, fakeSocket("editor"), { editLock: "el", title: "t" }, cb); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("editor", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "t" }, cb); expect(upsertDocumentMeta).not.toHaveBeenCalled(); + expect(broadcast.emit).not.toHaveBeenCalled(); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.COMMIT_UNAUTHORIZED })); }); @@ -36,4 +48,37 @@ describe("handleSetDocumentMeta", () => { })); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); }); + + it("broadcasts the meta update to room peers, not back to the sender", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "enc-title" }, cb); + expect(socket.to).toHaveBeenCalledWith("session::doc-1__room-did"); + expect(broadcast.emit).toHaveBeenCalledWith("/document/meta_update", { + roomId: "doc-1", + title: "enc-title", + }); + }); + + it("broadcasts a null title", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: null }, cb); + expect(broadcast.emit).toHaveBeenCalledWith("/document/meta_update", { + roomId: "doc-1", + title: null, + }); + }); + + it("does not broadcast when the session lookup fails", async () => { + const cb = vi.fn(); + const broadcast = { emit: vi.fn() }; + const socket = fakeSocket("owner", broadcast); + getRuntimeSession.mockResolvedValueOnce(null); + await handleSetDocumentMeta(deps, socket, { editLock: "el", title: "t" }, cb); + expect(broadcast.emit).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 })); + }); }); diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts index 86878b5..0aed7e0 100644 --- a/src/tests/services/socket-handlers.handleSnapshot.test.ts +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -23,6 +23,7 @@ describe("handleSnapshot", () => { authService: { verifyCollaborationToken } as any, sessionManager: { getRuntimeSession } as any, mongodbStore: { createSnapshot } as any, + editBoundCache: {} as any, }; it("rejects a non-owner with 403", async () => { diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index 7efcc0f..ee46de4 100644 --- a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -40,6 +40,7 @@ describe("updateHistory", () => { authService: {} as any, sessionManager: {} as any, mongodbStore: fakeMongodbStore as any, + 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 a6730b8..76d915a 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -43,12 +43,14 @@ describe("handleTerminateSession", () => { getSession: vi.fn(), deactivateSession: vi.fn(), terminateSession: vi.fn(), + updateSessionOwnerDid: vi.fn(), }; const fakeMongoDBStore = {} as any; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, mongodbStore: fakeMongoDBStore, + editBoundCache: {} as any, }; beforeEach(() => { @@ -308,6 +310,50 @@ describe("handleTerminateSession", () => { ); }); + it("lets the owner terminate after a rotation (heal instead of 401)", async () => { + const fetchSocketsResponse: any[] = []; + const fakeIO = createFakeIO(fetchSocketsResponse); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const fakeSessionResponse = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "did:key:old", + portalAddress: "0x0000000000000000000000000000000000000002", + appType: "ddoc" as const, + }; + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + fakeAuthService.verifyOwnerToken.mockResolvedValue("did:key:new"); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.updateSessionOwnerDid).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid, + "did:key:new" + ); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid, + "ddoc" + ); + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { message: "Session terminated" }, + }); + }); + it("returns 500 when an unexpected error occurs in terminate session handler", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); diff --git a/src/tests/utils/contract.test.ts b/src/tests/utils/contract.test.ts index 1d86421..362522b 100644 --- a/src/tests/utils/contract.test.ts +++ b/src/tests/utils/contract.test.ts @@ -31,3 +31,57 @@ describe("getIdentitySigningDid", () => { expect(did).toBeNull(); }); }); + +describe("bustOwnerDidCacheForPortal", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("drops all collaborator entries for the portal regardless of casing, leaves other portals", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockResolvedValue(["0xAgent", "did:key:zCollab"] as any); + + const portalMixedCase = "0xAaAa000000000000000000000000000000aAaA"; + const portalOppositeCase = "0xaAAA000000000000000000000000000000AaAa"; + const otherPortal = "0xBbBb000000000000000000000000000000bBbB"; + const collaboratorA = "0x1111000000000000000000000000000000cccc"; + const collaboratorB = "0x2222000000000000000000000000000000dddd"; + + await contract.getOwnerDid(portalMixedCase as any, collaboratorA as any); + await contract.getOwnerDid(otherPortal as any, collaboratorB as any); + expect(spy).toHaveBeenCalledTimes(2); + + contract.bustOwnerDidCacheForPortal(portalOppositeCase); + + // Busted portal: cache entry gone, so a fresh chain read happens. + await contract.getOwnerDid(portalMixedCase as any, collaboratorA as any); + expect(spy).toHaveBeenCalledTimes(3); + + // Other portal: cache entry untouched, no additional chain read. + await contract.getOwnerDid(otherPortal as any, collaboratorB as any); + expect(spy).toHaveBeenCalledTimes(3); + }); +}); + +describe("getPortalOwnerAddress", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reads owner() once and caches it", async () => { + const spy = vi + .spyOn(contract.publicClient, "readContract") + .mockResolvedValue("0xOwnerAddress0000000000000000000000000000"); + + const first = await contract.getPortalOwnerAddress("0xPortalOwnerAddr000000000000000000000000" as any); + const second = await contract.getPortalOwnerAddress("0xPortalOwnerAddr000000000000000000000000" as any); + + expect(first).toBe("0xOwnerAddress0000000000000000000000000000"); + expect(second).toBe("0xOwnerAddress0000000000000000000000000000"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "owner" }) + ); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 515511b..e9600ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,8 @@ export enum ErrorCode { NOT_AUTHENTICATED = "NOT_AUTHENTICATED", APP_MISMATCH = "APP_MISMATCH", JOIN_DISABLED = "JOIN_DISABLED", + EDIT_REVOKED = "EDIT_REVOKED", + ROOM_NOT_ESTABLISHED = "ROOM_NOT_ESTABLISHED", DB_ERROR = "DB_ERROR", INTERNAL_ERROR = "INTERNAL_ERROR", } @@ -110,9 +112,14 @@ export interface AuthArgs { contractAddress?: string; roomInfo?: string; appType?: AppType; - ownerIdentityDid?: string; identityToken?: string; - identityContractAddress?: string; + editUcan?: string; + actorHandle?: string; + /** Privilege-reducing join mode (workspace member): the server must never create or + * bind a session for this connection, and the role is capped at editor. */ + joinOnly?: boolean; + // in-place re-auth into the post-rotation sessionDid — suppress the membership blip and leave the old room silently. + rotationCutover?: boolean; } export interface AuthResponseData { @@ -120,6 +127,9 @@ export interface AuthResponseData { role: "owner" | "editor"; sessionType: "new" | "existing"; roomInfo?: string; + /** Latest stored roomKey-encrypted title (DocumentMeta) — fresher than the + * session-frozen roomInfo blob after a mid-session rename. */ + title?: string | null; } export interface DocumentUpdateArgs { @@ -147,6 +157,12 @@ export interface SnapshotArgs { floorSeq: number; } +export interface MirrorSnapshotArgs { + documentId?: string; + data: string; + fileKeyEpoch: number; +} + export interface DocumentMetaArgs { documentId?: string; editLock: string | null; @@ -251,6 +267,12 @@ export interface MembershipChangePayload { roomId: string; } +export interface MetaUpdatePayload { + roomId: string; + /** roomKey-encrypted; the server never sees the plaintext title. */ + title: string | null; +} + export interface SessionTerminatedPayload { roomId: string; } @@ -261,6 +283,24 @@ export interface ServerErrorPayload { roomId: string; } +export interface EpochAvailablePayload { + roomId: string; + epoch: number; + /** opaque, double-wrapped: inner = blob/appLock wrap (excludes the removed actor), + * outer = roomKey_e (excludes the server). The server never unwraps it. */ + payload: string; +} + +export interface CutoverPayload { + roomId: string; + epoch: number; +} + +export interface EpochLoadedArgs { + documentId: string; + epoch: number; +} + // *************************************** // Socket.IO Typed Event Maps // *************************************** @@ -277,10 +317,12 @@ export interface ClientToServerEvents { "/documents/commit/history": ClientEventHandler; "/documents/update/history": ClientEventHandler; "/documents/snapshot": ClientEventHandler; + "/documents/mirror-snapshot": ClientEventHandler; "/documents/meta": ClientEventHandler; "/documents/peers/list": ClientEventHandler; "/documents/awareness": ClientEventHandler; "/documents/terminate": ClientEventHandler; + "/session/epoch_loaded": ClientEventHandler; } export interface ServerToClientEvents { @@ -288,8 +330,11 @@ export interface ServerToClientEvents { "/server/error": (data: ServerErrorPayload) => void; "/document/content_update": (data: ContentUpdatePayload) => void; "/document/awareness_update": (data: AwarenessUpdatePayload) => void; + "/document/meta_update": (data: MetaUpdatePayload) => void; "/room/membership_change": (data: MembershipChangePayload) => void; "/session/terminated": (data: SessionTerminatedPayload) => void; + "/session/epoch_available": (data: EpochAvailablePayload) => void; + "/session/cutover": (data: CutoverPayload) => void; } export interface InterServerEvents { @@ -302,6 +347,11 @@ export interface SocketData { role: "owner" | "editor"; authenticated: boolean; appType: AppType; + rail?: "gp" | "workspace" | "public"; + railKind?: "gp-actor" | "workspace" | "public"; + actorHandle?: string; + actorIdentityDid?: string; + lastAdmitRecheckAt?: number; } // *************************************** @@ -352,6 +402,10 @@ export interface ServerConfig { serverDid: string; serverKeyPair?: any; }; + gate: { + url: string | undefined; + did: string | undefined; + }; rateLimit: { windowMs: number; max: number; @@ -363,7 +417,15 @@ export interface ServerConfig { interval: string; batchSize: number; }; + deleteGrace: { + windowMs: number; + interval: string; + batchSize: number; + }; agenda: { concurrency: number; }; + webhook: { + apiKey?: string; + }; } \ No newline at end of file diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 2c86b8e..afa2fac 100644 Binary files a/src/utils/contract.ts and b/src/utils/contract.ts differ