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
3 changes: 3 additions & 0 deletions src/database/models/document-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface IDocumentMeta extends MongooseDocument {
ownerDid: string | null;
ownerIdentityDid: string | null;
portalAddress: string | null;
ddocId: string | null; // on-chain id when documentId is an opaque o- id; owner-only, never emitted
appType?: "ddoc" | "dsheet";
editLock: string | null; // roomKey-wrapped
title: string | null; // roomKey-encrypted
Expand All @@ -22,6 +23,7 @@ const DocumentMetaSchema = new Schema<IDocumentMeta>({
ownerDid: { type: String, default: null },
ownerIdentityDid: { type: String, default: null },
portalAddress: { type: String, default: null },
ddocId: { type: String, default: null },
// Which Fileverse app owns this document. Absent ⇒ "ddoc" (legacy).
appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" },
editLock: { type: String, default: null },
Expand All @@ -40,6 +42,7 @@ DocumentMetaSchema.index({ ownerIdentityDid: 1, isPublished: 1 }, { background:
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 });
DocumentMetaSchema.index({ ddocId: 1, portalAddress: 1 }, { background: true, sparse: true });

export const DocumentMetaModel = mongoose.model<IDocumentMeta>("DocumentMeta", DocumentMetaSchema);
export type { IDocumentMeta };
20 changes: 16 additions & 4 deletions src/services/deleted-file-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@ export function createDeletedFileWebhookHandler(deps: DeletedFileWebhookDeps) {
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()) {
// ddocId is client-asserted, so a row on another portal can claim the same id: every
// candidate is portal-matched, never just the first one found.
const rows = await DocumentMetaModel.find({
$or: [{ _id: appFileId }, { ddocId: appFileId }],
portalAddress: { $ne: null },
})
.select("_id portalAddress")
.lean();
const portal = portalAddress.toLowerCase();
const matches = rows.filter((m) => (m.portalAddress ?? "").toLowerCase() === portal);
if (matches.length === 0) {
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);
for (const meta of matches) {
const documentId = String(meta._id);
const done = await deps.mongodbStore.tombstoneDocument(documentId, "onchain-delete");
if (done && deps.onTombstoned) await deps.onTombstoned(documentId);
}
res.status(200).json({ ok: true, matched: true });
};
}
61 changes: 53 additions & 8 deletions src/services/mongodb-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const HYDRATION_SLOW_MS = 500;
const HYDRATION_LARGE_BYTES = 2 * 1024 * 1024;
const HYDRATION_LARGE_ROWS = 2_000;

export const acceptedDdocId = (documentId: string, ddocId: unknown): string | null =>
typeof ddocId === "string" &&
ddocId.length > 0 &&
ddocId.length <= 64 &&
documentId.startsWith("o-") &&
!ddocId.startsWith("o-")
? ddocId
: null;

export class MongoDBStore {
// Update management
async createUpdate(update: DocumentUpdate): Promise<DocumentUpdate> {
Expand Down Expand Up @@ -303,6 +312,7 @@ export class MongoDBStore {
ownerDid: string | null;
ownerIdentityDid: string | null;
portalAddress: string | null;
ddocId: string | null;
appType: AppType;
editLock: string | null;
title: string | null;
Expand All @@ -314,6 +324,7 @@ export class MongoDBStore {
ownerDid: meta.ownerDid,
ownerIdentityDid: meta.ownerIdentityDid,
portalAddress: meta.portalAddress,
ddocId: meta.ddocId,
appType: meta.appType,
},
$set: {
Expand All @@ -325,6 +336,12 @@ export class MongoDBStore {
},
{ upsert: true, new: true, writeConcern: { w: "majority", j: true } }
);
if (meta.ddocId) {
await DocumentMetaModel.updateOne(
{ _id: meta.documentId, $or: [{ ddocId: null }, { ddocId: { $exists: false } }] },
{ $set: { ddocId: meta.ddocId } }
);
}
}

// First-writer-immutable document->owner binding (see docs/architecture/edit-permission.md).
Expand All @@ -340,6 +357,7 @@ export class MongoDBStore {
ownerDid: string | null;
ownerIdentityDid: string | null;
sessionDid: string;
ddocId: string | null;
appType: AppType;
}): Promise<{ portalAddress: string | null }> {
// 1. Ensure the row exists; pin all binding fields on INSERT only.
Expand All @@ -353,6 +371,7 @@ export class MongoDBStore {
sessionDid: p.sessionDid,
updatedAt: Date.now(),
isPublished: false,
ddocId: p.ddocId,
appType: p.appType,
},
},
Expand All @@ -365,7 +384,16 @@ export class MongoDBStore {
);
// 3. Read the effective pin.
const doc: any = await DocumentMetaModel.findById(p.documentId).select("portalAddress").lean();
return { portalAddress: doc?.portalAddress ?? null };
const effective: string | null = doc?.portalAddress ?? null;
// 4. Backfill ddocId only for the pinned portal: the caller is rejected on a mismatch, and
// a write-once value must not be settable by that rejected caller.
if (p.ddocId && effective && effective.toLowerCase() === p.portalAddress.toLowerCase()) {
await DocumentMetaModel.updateOne(
{ _id: p.documentId, $or: [{ ddocId: null }, { ddocId: { $exists: false } }] },
{ $set: { ddocId: p.ddocId } }
);
}
return { portalAddress: effective };
}

async getDocumentMeta(
Expand Down Expand Up @@ -439,19 +467,26 @@ export class MongoDBStore {
async listDocumentsForOwner(
by: { ownerIdentityDid?: string; ownerDid?: string; portalAddress?: string }
): Promise<{
documents: Array<{ documentId: string; editLock: string | null; title: string | null; appType: AppType }>;
documents: Array<{
documentId: string;
ddocId: string | null;
editLock: string | null;
title: string | null;
appType: AppType;
}>;
deletedDocumentIds: string[];
deletedDocuments: Array<{ documentId: string; ddocId: string | null }>;
}> {
const filter: Record<string, any> = {};
if (by.ownerIdentityDid) filter.ownerIdentityDid = by.ownerIdentityDid;
else if (by.ownerDid) filter.ownerDid = by.ownerDid;
else return { documents: [], deletedDocumentIds: [] };
else return { documents: [], deletedDocumentIds: [], deletedDocuments: [] };
// Published docs are discovered via the indexer; the collab server only lists
// unpublished durable docs (the publish reconciler flips this flag).
filter.isPublished = { $ne: true };

const metas: any[] = await DocumentMetaModel.find(filter)
.select("editLock title appType portalAddress tombstonedAt")
.select("editLock title appType portalAddress tombstonedAt ddocId")
.lean();

// Scope to the portal the caller proved. A team editLock is an identity envelope and
Expand All @@ -473,27 +508,37 @@ export class MongoDBStore {
.filter((m) => !m.tombstonedAt)
.map((m) => ({
documentId: m._id,
ddocId: m.ddocId ?? null,
editLock: m.editLock ?? null,
title: m.title ?? null,
appType: (m.appType as AppType) ?? "ddoc",
}));
const deletedDocumentIds = scoped.filter((m) => m.tombstonedAt).map((m) => String(m._id));
return { documents, deletedDocumentIds };
const deletedDocuments = scoped
.filter((m) => m.tombstonedAt)
.map((m) => ({ documentId: String(m._id), ddocId: m.ddocId ?? null }));
return { documents, deletedDocumentIds, deletedDocuments };
}

// Publish-reconciler candidate set: unpublished durable docs that have a portal to
// resolve on-chain. Rows without a portalAddress can't be checked, so they are skipped.
async listUnpublishedMetaRefs(
limit: number
): Promise<Array<{ documentId: string; portalAddress: string }>> {
): Promise<Array<{ documentId: string; portalAddress: string; ddocId: string | null }>> {
const rows: any[] = await DocumentMetaModel.find({
isPublished: { $ne: true },
portalAddress: { $ne: null },
$or: [{ _id: { $not: /^o-/ } }, { ddocId: { $ne: null } }],
})
.select("portalAddress")
.select("portalAddress ddocId")
.sort({ updatedAt: -1 })
.limit(limit)
.lean();
return rows.map((r) => ({ documentId: r._id, portalAddress: r.portalAddress }));
return rows.map((r) => ({
documentId: r._id,
portalAddress: r.portalAddress,
ddocId: r.ddocId ?? null,
}));
}

async markDocumentsPublished(
Expand Down
11 changes: 6 additions & 5 deletions src/services/owner-op-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,12 @@ export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) {
if (signingDid) {
// portalAddress is safe to filter on: verifyIdentityToken only returns a signingDid
// when the UCAN was signed for exactly this hierPart.
const { documents, deletedDocumentIds } = await deps.mongodbStore.listDocumentsForOwner({
ownerIdentityDid: signingDid,
portalAddress,
});
res.status(200).json({ documents, deletedDocumentIds });
const { documents, deletedDocumentIds, deletedDocuments } =
await deps.mongodbStore.listDocumentsForOwner({
ownerIdentityDid: signingDid,
portalAddress,
});
res.status(200).json({ documents, deletedDocumentIds, deletedDocuments });
return;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/published-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { MongoDBStore } from "./mongodb-store";
export const reconcilePublishedDocuments = async (deps: {
mongodbStore: Pick<MongoDBStore, "listUnpublishedMetaRefs" | "markDocumentsPublished">;
resolvePublishedDocumentIds: (
refs: Array<{ documentId: string; portalAddress: string }>
refs: Array<{ documentId: string; portalAddress: string; ddocId: string | null }>
) => Promise<Array<{ documentId: string; fileId: string }>>;
batchSize: number;
}): Promise<{ scanned: number; published: number }> => {
Expand Down
4 changes: 3 additions & 1 deletion src/services/socket-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from "../types/index";
import { requireAuth } from "./auth-middleware";
import { authService } from "./auth";
import { mongodbStore, SessionTerminatedError } from "./mongodb-store";
import { mongodbStore, SessionTerminatedError, acceptedDdocId } from "./mongodb-store";
import { sessionManager } from "./session-manager";
import { rotationCoordinator } from "./rotation-coordinator";
import { Hex, isAddress } from "viem";
Expand Down Expand Up @@ -393,6 +393,7 @@ export async function handleAuth(
ownerDid,
ownerIdentityDid: boundOwnerIdentityDid,
sessionDid,
ddocId: acceptedDdocId(documentId, args.ddocId),
appType: claimedAppType,
});

Expand Down Expand Up @@ -1418,6 +1419,7 @@ export async function handleSetDocumentMeta(
ownerDid: session.ownerDid ?? null,
ownerIdentityDid: session.ownerIdentityDid ?? null,
portalAddress: session.portalAddress ?? null,
ddocId: acceptedDdocId(documentId, args.ddocId),
appType: socket.data.appType,
editLock: args.editLock,
title: args.title,
Expand Down
20 changes: 10 additions & 10 deletions src/tests/services/deleted-file-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const findById = vi.fn();
const find = vi.fn();

vi.mock("../../database/models", () => ({
DocumentMetaModel: { findById: (...a: unknown[]) => findById(...a) },
DocumentMetaModel: { find: (...a: unknown[]) => find(...a) },
DocumentUpdateModel: {}, DocumentCommitModel: {}, CounterModel: {},
SessionModel: {}, DocumentMirrorModel: {}, DocumentEditEpochModel: {},
}));
Expand Down Expand Up @@ -40,8 +40,8 @@ describe("POST /webhooks/file-deleted", () => {
});

it("tombstones and drops sessions on valid secret + portal match", async () => {
findById.mockReturnValue({
select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xAbC" }) }),
find.mockReturnValue({
select: () => ({ lean: () => Promise.resolve([{ _id: "doc-1", portalAddress: "0xAbC" }]) }),
});
const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned });
const r = res();
Expand All @@ -68,12 +68,12 @@ describe("POST /webhooks/file-deleted", () => {

expect(r.status).toHaveBeenCalledWith(401);
expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled();
expect(findById).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
});

it("no-ops on portal mismatch (colliding-appFileId defense) and never tombstones", async () => {
findById.mockReturnValue({
select: () => ({ lean: () => Promise.resolve({ portalAddress: "0xOwnerPortal" }) }),
find.mockReturnValue({
select: () => ({ lean: () => Promise.resolve([{ _id: "doc-1", portalAddress: "0xOwnerPortal" }]) }),
});
const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned });
const r = res();
Expand All @@ -89,7 +89,7 @@ describe("POST /webhooks/file-deleted", () => {
});

it("no-ops on unknown appFileId and never tombstones", async () => {
findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(null) }) });
find.mockReturnValue({ select: () => ({ lean: () => Promise.resolve([]) }) });
const handler = createDeletedFileWebhookHandler({ mongodbStore, onTombstoned });
const r = res();

Expand Down Expand Up @@ -117,7 +117,7 @@ describe("POST /webhooks/file-deleted", () => {

expect(r.status).toHaveBeenCalledWith(401);
expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled();
expect(findById).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
} finally {
config.webhook.apiKey = original;
}
Expand All @@ -131,6 +131,6 @@ describe("POST /webhooks/file-deleted", () => {

expect(r.status).toHaveBeenCalledWith(400);
expect(mongodbStore.tombstoneDocument).not.toHaveBeenCalled();
expect(findById).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
});
});
7 changes: 7 additions & 0 deletions src/tests/services/mongodb-store.pinPortal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("pinDocumentPortalIfAbsent", () => {
ownerDid: "did:key:o",
ownerIdentityDid: "did:key:i",
sessionDid: "did:key:s1",
ddocId: null,
appType: "ddoc",
});

Expand All @@ -63,6 +64,7 @@ describe("pinDocumentPortalIfAbsent", () => {
ownerDid: "did:key:o",
ownerIdentityDid: "did:key:i",
sessionDid: "did:key:s1",
ddocId: null,
appType: "ddoc",
});

Expand All @@ -81,6 +83,7 @@ describe("pinDocumentPortalIfAbsent", () => {
ownerDid: "did:key:o2",
ownerIdentityDid: "did:key:i2",
sessionDid: "did:key:s2",
ddocId: null,
appType: "ddoc",
});

Expand All @@ -101,6 +104,7 @@ describe("pinDocumentPortalIfAbsent", () => {
ownerDid: null,
ownerIdentityDid: "did:key:i",
sessionDid: "did:key:s",
ddocId: null,
appType: "ddoc",
});

Expand All @@ -117,6 +121,7 @@ describe("pinDocumentPortalIfAbsent", () => {
ownerDid: null,
ownerIdentityDid: null,
sessionDid: "did:key:s",
ddocId: null,
appType: "ddoc",
});

Expand All @@ -138,6 +143,7 @@ describe("upsertDocumentMeta binding immutability", () => {
ownerDid: "did:key:x",
ownerIdentityDid: "did:key:x",
portalAddress: "0xEVIL",
ddocId: null,
appType: "ddoc",
editLock: "lk",
title: "t",
Expand All @@ -150,6 +156,7 @@ describe("upsertDocumentMeta binding immutability", () => {
ownerDid: "did:key:x",
ownerIdentityDid: "did:key:x",
portalAddress: "0xEVIL",
ddocId: null,
appType: "ddoc",
},
$set: expect.objectContaining({
Expand Down
Loading
Loading