diff --git a/src/database/models/document-meta.ts b/src/database/models/document-meta.ts index 739ff2d..21bc208 100644 --- a/src/database/models/document-meta.ts +++ b/src/database/models/document-meta.ts @@ -6,7 +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 + ddocId: string | null; // on-chain id when documentId is an opaque link id; owner-only, never emitted appType?: "ddoc" | "dsheet"; editLock: string | null; // roomKey-wrapped title: string | null; // roomKey-encrypted diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index 0258ec7..16f23f4 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -14,12 +14,15 @@ const HYDRATION_SLOW_MS = 500; const HYDRATION_LARGE_BYTES = 2 * 1024 * 1024; const HYDRATION_LARGE_ROWS = 2_000; +// An opaque link id, by exact shape: addresses, hashes and on-chain ids can also start with 0x. +const LINK_ID_RE = /^0x[1-9A-HJ-NP-Za-km-z]{18}$/; + export const acceptedDdocId = (documentId: string, ddocId: unknown): string | null => typeof ddocId === "string" && ddocId.length > 0 && ddocId.length <= 64 && - documentId.startsWith("o-") && - !ddocId.startsWith("o-") + LINK_ID_RE.test(documentId) && + !LINK_ID_RE.test(ddocId) ? ddocId : null; @@ -528,7 +531,7 @@ export class MongoDBStore { const rows: any[] = await DocumentMetaModel.find({ isPublished: { $ne: true }, portalAddress: { $ne: null }, - $or: [{ _id: { $not: /^o-/ } }, { ddocId: { $ne: null } }], + $or: [{ _id: { $not: LINK_ID_RE } }, { ddocId: { $ne: null } }], }) .select("portalAddress ddocId") .sort({ updatedAt: -1 }) diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index b34b360..bad5feb 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, SessionTerminatedError } from "../../services/mongodb-store"; +import { MongoDBStore, SessionTerminatedError, acceptedDdocId } from "../../services/mongodb-store"; vi.mock("../../database/models", () => { const save = vi.fn().mockResolvedValue(undefined); @@ -313,7 +313,7 @@ describe("listDocumentsForOwner", () => { expect(DocumentMetaModel.find).toHaveBeenCalledWith({ isPublished: { $ne: true }, portalAddress: { $ne: null }, - $or: [{ _id: { $not: /^o-/ } }, { ddocId: { $ne: null } }], + $or: [{ _id: { $not: /^0x[1-9A-HJ-NP-Za-km-z]{18}$/ } }, { ddocId: { $ne: null } }], }); expect(sort).toHaveBeenCalledWith({ updatedAt: -1 }); expect(limit).toHaveBeenCalledWith(500); @@ -794,3 +794,28 @@ describe("lockWireFormat: duplicate-key race", () => { await expect(store.lockWireFormat("doc-1")).resolves.toBe(false); }); }); + +describe("acceptedDdocId", () => { + const LINK_ID = "0x" + "Zk".repeat(9); + const DDOC_ID = "7sLHvGNqsvNDhRm8tPKZSu"; + + it("keeps the on-chain id for a room named by a link id", () => { + expect(acceptedDdocId(LINK_ID, DDOC_ID)).toBe(DDOC_ID); + }); + + it("keeps an on-chain id that itself starts with 0x", () => { + expect(acceptedDdocId(LINK_ID, "0xabc")).toBe("0xabc"); + }); + + it("refuses a link id as the on-chain id", () => { + expect(acceptedDdocId(LINK_ID, "0x" + "Gh".repeat(9))).toBeNull(); + }); + + it.each([ + ["a plain room", DDOC_ID], + ["a room named by an address", "0x" + "a1".repeat(20)], + ["a room with the old o- prefix", "o-" + "Zk".repeat(9)], + ])("stores nothing for %s", (_, documentId) => { + expect(acceptedDdocId(documentId, DDOC_ID)).toBeNull(); + }); +});