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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/database/models/document-commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface IDocumentCommit extends MongooseDocument {
updates: string[];
createdAt: number;
sessionDid: string;
appType: "ddoc" | "dsheet";
}

const DocumentCommitSchema = new Schema<IDocumentCommit>({
Expand All @@ -20,12 +21,17 @@ const DocumentCommitSchema = new Schema<IDocumentCommit>({
updates: [{ type: String }],
createdAt: { type: Number, required: true, index: true },
sessionDid: { type: String, required: true },
// Which Fileverse app produced this commit. Absent ⇒ "ddoc" (legacy).
appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" },
});

DocumentCommitSchema.index({ documentId: 1, createdAt: -1 }, { background: true });

DocumentCommitSchema.index({ documentId: 1, sessionDid: 1 }, { background: true });

// Supports per-app lifecycle/analytics queries (e.g. "all dsheet commits").
DocumentCommitSchema.index({ appType: 1, createdAt: 1 }, { background: true });

export const DocumentCommitModel = mongoose.model<IDocumentCommit>(
"DocumentCommit",
DocumentCommitSchema
Expand Down
6 changes: 6 additions & 0 deletions src/database/models/document-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface IDocumentUpdate extends MongooseDocument {
commitCid: string | null;
createdAt: number;
sessionDid: string;
appType: "ddoc" | "dsheet";
}

const DocumentUpdateSchema = new Schema<IDocumentUpdate>({
Expand All @@ -20,6 +21,8 @@ const DocumentUpdateSchema = new Schema<IDocumentUpdate>({
commitCid: { type: String, default: null },
createdAt: { type: Number, required: true, index: true },
sessionDid: { type: String, required: true },
// Which Fileverse app produced this update. Absent ⇒ "ddoc" (legacy).
appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" },
});

DocumentUpdateSchema.index({ documentId: 1, committed: 1, createdAt: 1 }, { background: true });
Expand All @@ -34,6 +37,9 @@ DocumentUpdateSchema.index(

DocumentUpdateSchema.index({ documentId: 1, sessionDid: 1 }, { background: true });

// Supports per-app lifecycle/analytics queries (e.g. "all dsheet updates").
DocumentUpdateSchema.index({ appType: 1, createdAt: 1 }, { background: true });

export const DocumentUpdateModel = mongoose.model<IDocumentUpdate>(
"DocumentUpdate",
DocumentUpdateSchema
Expand Down
3 changes: 3 additions & 0 deletions src/database/models/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ISession extends MongooseDocument {
createdAt: Date;
state: "active" | "inactive" | "terminated";
roomInfo: string;
appType: "ddoc" | "dsheet";
}

const SessionSchema = new Schema({
Expand All @@ -16,6 +17,8 @@ const SessionSchema = new Schema({
createdAt: { type: Date, default: Date.now },
state: { type: String, enum: ["active", "inactive", "terminated"], default: "active" },
roomInfo: { type: String, default: null },
// Which Fileverse app owns this session. Absent ⇒ "ddoc" (legacy).
appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" },
});

SessionSchema.index({ documentId: 1, createdAt: 1, sessionDid: 1 });
Expand Down
46 changes: 46 additions & 0 deletions src/scripts/backfill-apptype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* One-time backfill: tag every pre-`appType` row as "ddoc".
*
* The collaboration server is shared between ddoc and dsheet. dsheet is the first
* app to write the `appType` field, so every row that predates it belongs to ddoc.
* Running this makes every row explicit, so per-app queries are plain equality
* with no null-handling.
*
* Idempotent — only touches rows where `appType` is missing. Safe to re-run.
*
* Usage: tsx src/scripts/backfill-apptype.ts
*/
import "dotenv/config";
import { databaseService } from "../database";
import {
DocumentUpdateModel,
DocumentCommitModel,
SessionModel,
} from "../database/models";

async function backfill(): Promise<void> {
await databaseService.connect();

const filter = { appType: { $exists: false } };
const set = { $set: { appType: "ddoc" as const } };

const [updates, commits, sessions] = await Promise.all([
DocumentUpdateModel.updateMany(filter, set),
DocumentCommitModel.updateMany(filter, set),
SessionModel.updateMany(filter, set),
]);

console.log("appType backfill complete:");
console.log(` DocumentUpdate: ${updates.modifiedCount} tagged "ddoc"`);
console.log(` DocumentCommit: ${commits.modifiedCount} tagged "ddoc"`);
console.log(` Session: ${sessions.modifiedCount} tagged "ddoc"`);

await databaseService.disconnect();
}

backfill()
.then(() => process.exit(0))
.catch((err) => {
console.error("appType backfill failed:", err);
process.exit(1);
});
1 change: 1 addition & 0 deletions src/services/auth-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function authMiddleware(socket: AppSocket, next: (err?: Error) => void):
socket.data.documentId = "";
socket.data.sessionDid = "";
socket.data.role = "editor";
socket.data.appType = "ddoc";

next();
}
Expand Down
2 changes: 2 additions & 0 deletions src/services/mongodb-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class MongoDBStore {
commitCid: update.commitCid,
createdAt: update.createdAt,
sessionDid: update.sessionDid,
appType: update.appType,
});

await mongoUpdate.save();
Expand Down Expand Up @@ -119,6 +120,7 @@ export class MongoDBStore {
updates: commit.updates,
createdAt: commit.createdAt,
sessionDid: commit.sessionDid,
appType: commit.appType,
});

await mongoCommit.save();
Expand Down
8 changes: 7 additions & 1 deletion src/services/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface RuntimeSession {
ownerDid: string;
clients: Set<string>;
roomInfo?: string;
appType?: "ddoc" | "dsheet";
}

export class SessionManager {
Expand Down Expand Up @@ -55,7 +56,11 @@ export class SessionManager {
sessionDid: sessionData.sessionDid,
ownerDid: sessionData.ownerDid,
},
{ state: "active", roomInfo: sessionData.roomInfo },
{
state: "active",
roomInfo: sessionData.roomInfo,
appType: sessionData.appType ?? "ddoc",
},
{ upsert: true, new: true }
);
} catch (error) {
Expand Down Expand Up @@ -87,6 +92,7 @@ export class SessionManager {
ownerDid: dbSession.ownerDid,
clients: new Set<string>(),
roomInfo: dbSession.roomInfo,
appType: dbSession.appType,
};

// Store in memory
Expand Down
31 changes: 31 additions & 0 deletions src/services/socket-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DocumentUpdate,
AppServer,
AppSocket,
AppType,
ErrorCode,
} from "../types/index";
import { requireAuth } from "./auth-middleware";
Expand Down Expand Up @@ -45,6 +46,15 @@ export function getRoomName(documentId: string, sessionDid: string): string {
return `session::${documentId}__${sessionDid}`;
}

/**
* Coerce an untrusted appType into a known AppType. Anything that is not exactly
* "dsheet" becomes "ddoc" (the legacy default), so missing or invalid values
* never fail enum validation and never widen access during the isolation check.
*/
function normalizeAppType(value: unknown): AppType {
return value === "dsheet" ? "dsheet" : "ddoc";
}

export function registerEventHandlers(io: AppServer, broadcastBridge?: BroadcastBridge): void {
if (broadcastBridge) {
bridge = broadcastBridge;
Expand Down Expand Up @@ -100,6 +110,7 @@ export async function handleAuth(
try {
const { authService, sessionManager } = deps;
const { documentId, collaborationToken, sessionDid } = args;
const claimedAppType = normalizeAppType(args.appType);

if (!collaborationToken) {
return callback({
Expand Down Expand Up @@ -133,6 +144,7 @@ export async function handleAuth(
let role: "owner" | "editor";
let sessionType: "new" | "existing";
let roomInfo: string | undefined;
let resolvedAppType: AppType;

if (!existingSession && args.ownerToken) {
// - Set up a new session (owner flow)
Expand Down Expand Up @@ -214,11 +226,13 @@ export async function handleAuth(
sessionDid,
ownerDid,
roomInfo: args.roomInfo,
appType: claimedAppType,
});

role = "owner";
sessionType = "new";
roomInfo = args.roomInfo;
resolvedAppType = claimedAppType;
} else if (existingSession) {
// Join an existing session
const userDid = await authService.verifyCollaborationToken(
Expand All @@ -236,6 +250,19 @@ export async function handleAuth(
});
}

// Isolation guard: a document belongs to exactly one app. Reject a client
// whose declared app (missing ⇒ "ddoc") differs from the document's stored
// app (missing ⇒ "ddoc"), so ddoc and dsheet can never join the same room.
const storedAppType = normalizeAppType(existingSession.appType);
if (claimedAppType !== storedAppType) {
return callback({
status: false,
statusCode: 403,
error: "App type mismatch for this document",
errorCode: ErrorCode.APP_MISMATCH,
});
}

let ownerDid = null;
if (args.ownerToken && args.ownerAddress && args.contractAddress) {
if (!validateHexAddress(args.contractAddress, "contractAddress") ||
Expand Down Expand Up @@ -267,6 +294,7 @@ export async function handleAuth(

sessionType = "existing";
roomInfo = existingSession.roomInfo;
resolvedAppType = storedAppType;
} else {
return callback({
status: false,
Expand All @@ -281,6 +309,7 @@ export async function handleAuth(
socket.data.documentId = documentId;
socket.data.sessionDid = sessionDid;
socket.data.role = role;
socket.data.appType = resolvedAppType;

// Join the Socket.IO room
const roomName = getRoomName(documentId, sessionDid);
Expand Down Expand Up @@ -410,6 +439,7 @@ export async function handleDocumentUpdate(
commitCid: null,
createdAt,
sessionDid,
appType: socket.data.appType,
});

callback({
Expand Down Expand Up @@ -518,6 +548,7 @@ export async function handleDocumentCommit(
updates,
createdAt: Date.now(),
sessionDid,
appType: socket.data.appType,
});

callback({
Expand Down
58 changes: 58 additions & 0 deletions src/tests/services/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,62 @@ describe("SessionManager", () => {

expect(clients).toBeUndefined();
});

// appType

it("persists the provided appType and exposes it on the in-memory session", async () => {
const freshManager = new SessionManager();

await freshManager.createSession({
documentId: "doc-2",
sessionDid: "session-2",
ownerDid: "owner-did",
appType: "dsheet",
});

expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith(
{ documentId: "doc-2", sessionDid: "session-2", ownerDid: "owner-did" },
expect.objectContaining({ appType: "dsheet" }),
{ upsert: true, new: true }
);
const session = await freshManager.getSession("doc-2", "session-2");
expect(session?.appType).toBe("dsheet");

await freshManager.destroy();
});

it("defaults appType to ddoc when none is provided", async () => {
const freshManager = new SessionManager();

await freshManager.createSession({
documentId: "doc-3",
sessionDid: "session-3",
ownerDid: "owner-did",
});

expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith(
{ documentId: "doc-3", sessionDid: "session-3", ownerDid: "owner-did" },
expect.objectContaining({ appType: "ddoc" }),
{ upsert: true, new: true }
);

await freshManager.destroy();
});

it("hydrates appType from the database on fallback", async () => {
const freshManager = new SessionManager();
vi.mocked(SessionModel.findOne).mockResolvedValue({
documentId: "doc-1",
sessionDid: "session-1",
ownerDid: "owner-did",
roomInfo: "room-info",
appType: "dsheet",
} as any);

const session = await freshManager.getSession("doc-1", "session-1");

expect(session?.appType).toBe("dsheet");

await freshManager.destroy();
});
});
Loading
Loading