diff --git a/docs/data-model.md b/docs/data-model.md index 3c0f9bdd..d04cc2f8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -499,9 +499,15 @@ No document Solid Memo names has any. and `isDraftDocument` in [deckPlace.ts](../packages/solid/src/deckPlace.ts)). - **An older document that doesn't say so** (from an earlier version, a - repair or a copy) is the deck's in the instance's own folder, unless - that folder is the root of a pod or a storage (typed `pim:Storage`). - There the next write states it. One that names any other catalog's + repair or a copy) is the deck's in the instance's own folder. That + folder isn't the instance's own when it's the root of a pod or a + storage, or a folder above one. Such a root is one of the user's + storages (as above), which a server may leave untyped, or a root the + server shows (typed `pim:Storage`, or named by a Linked Web Storage + server). In the instance's own folder the next write states it. When + the profile can't be read, a write fails and can be retried. The data + check and a removal then read the document, and take it for unclaimed + unless it says it's the deck's. One that names any other catalog's entry (such as a nested instance's) isn't the deck's: the app never adds a second owner on its own, and refuses the write (`deckDocumentUnclaimed`). The data check doesn't read a document in diff --git a/packages/solid/src/deckPlace.test.ts b/packages/solid/src/deckPlace.test.ts index 7bea5d95..e50812ec 100644 --- a/packages/solid/src/deckPlace.test.ts +++ b/packages/solid/src/deckPlace.test.ts @@ -1074,6 +1074,85 @@ describe("an instance at the pod's root, laid out flat", () => { ).resolves.toEqual([]); }); + describe("on a server that holds the pods of many on one origin and types none of their roots", () => { + const pod = `${ROOT}alice/`; + const SECRET = `<#entry> "secret" .`; + const at = (instance: string, document = DIARY) => ({ + url: `${instance}catalog.ttl#deck-1`, + title: { en: "Spanish" }, + cardsDocumentUrl: document, + reviewsDocumentUrl: document, + }); + + /** A guard over a server that types no folder and holds `documents` (address to Turtle), each request kept in `asked`. */ + function untyped( + userStorages: () => Promise, + documents: Record = { [DIARY]: SECRET }, + ) { + const asked: string[] = []; + const fetch = (async (input, init) => { + const method = init?.method ?? "GET"; + asked.push(`${method} ${String(input)}`); + const body = documents[String(input)]; + if (method === "HEAD" || body === undefined) return new Response(null, { status: 200 }); + return new Response(body, { headers: { "Content-Type": "text/turtle", ETag: '"e1"' } }); + }) as typeof globalThis.fetch; + return { guard: createDeckDocumentGuard(fetch, userStorages), asked }; + } + + it("never marks an older document as a deck's by itself at a pod's root the user's storages name", async () => { + const diary = await datasetOfTurtle(DIARY, SECRET); + // However the storage is spelled: host case, default port, no trailing slash. + for (const storage of [pod, "https://POD.example:443/alice"]) { + const { guard, asked } = untyped(async () => [storage]); + await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed"); + await expect(guard.writable(at(pod), DIARY, diary)).rejects.toMatchObject({ + code: "deckDocumentUnclaimed", + }); + await expect(guard.removal(at(pod), DIARY)).resolves.toBe("unclaimed"); + await expect(guard.unclaimed(at(pod))).resolves.toEqual([DIARY]); + // The user's storages decide it: the folder is never asked. + expect(asked.filter((one) => one.startsWith("HEAD"))).toEqual([]); + } + }); + + it("never marks one as a deck's in a folder above the user's storage either", async () => { + const diary = await datasetOfTurtle(DIARY, SECRET); + const { guard } = untyped(async () => [pod]); + const above = `${ROOT}pods/`; + const inside = `${above}alice/private/diary.ttl`; + const read = await datasetOfTurtle(inside, `<#entry> "x" .`); + const nested = untyped(async () => [`${above}alice/`]); + await expect(nested.guard.standing(at(above, inside), inside, read)).resolves.toBe( + "unclaimed", + ); + // An instance in its own folder in the user's pod still marks an older document as the deck's. + const own = `${pod}memo/`; + const mine = `${own}decks/deck-1.ttl`; + const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno")); + await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("adoptable"); + await expect(guard.removal(at(own, mine), mine)).resolves.toBe("deletable"); + await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([]); + await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed"); + }); + + it("leaves an older document no deck's when the user's storages cannot be found, and refuses a write for now", async () => { + const own = `${pod}memo/`; + const mine = `${own}decks/deck-1.ttl`; + const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno")); + const { guard } = untyped(() => Promise.reject(new Error("profile unreadable")), { + [mine]: cardTurtle("card-1", "uno"), + }); + await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("unclaimed"); + await expect(guard.writable(at(own, mine), mine, older)).rejects.toMatchObject({ + code: "cannotCheck", + vars: { url: own, status: 0 }, + }); + await expect(guard.removal(at(own, mine), mine)).resolves.toBe("unclaimed"); + await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([mine]); + }); + }); + it("asks again a folder whose storage description can't be read", async () => { const p = await pod(); let heads = 0; diff --git a/packages/solid/src/deckPlace.ts b/packages/solid/src/deckPlace.ts index f0c65141..343d56f3 100644 --- a/packages/solid/src/deckPlace.ts +++ b/packages/solid/src/deckPlace.ts @@ -177,10 +177,25 @@ export function createDeckDocumentGuard( /** * Whether the folder is an instance's own: not a pod's root (`/`), nor - * a storage's (isStorageRoot), which hold the user's other data too. + * a storage's root or a folder above one, which hold the user's other + * data too. A storage's root is one of the user's storages + * (userStorages), which a server that holds many users' pods on one + * origin may leave untyped, or one the server shows (isStorageRoot). + * A storage list the pod does not answer for leaves it not shown to + * be: `lenient`, so the instance's documents are read and kept as + * they are; otherwise cannotCheck, so that a write may be tried again. */ - async function isDedicatedFolder(folder: string): Promise { - return new URL(folder).pathname !== "/" && !(await isStorageRoot(folder)); + async function isDedicatedFolder(folder: string, lenient: boolean): Promise { + if (new URL(folder).pathname === "/") return false; + let storages: readonly string[]; + try { + storages = await userStorages(); + } catch { + if (lenient) return false; + throw new AppError("cannotCheck", { url: folder, status: 0 }); + } + if (storages.some((storage) => isInFolder(ensureTrailingSlash(storage), folder))) return false; + return !(await isStorageRoot(folder)); } /** @@ -279,7 +294,7 @@ export function createDeckDocumentGuard( if (dataset !== null && ownsDocument(partOfOf(dataset, url), deck.url)) return "owned"; if (dataset === null) return "adoptable"; if (deckBackLinksOf(dataset, url).length > 0) return "unclaimed"; - return (await isDedicatedFolder(instance)) ? "adoptable" : "unclaimed"; + return (await isDedicatedFolder(instance, lenient)) ? "adoptable" : "unclaimed"; } /** @@ -299,7 +314,7 @@ export function createDeckDocumentGuard( const found: string[] = []; for (const url of [deck.cardsDocumentUrl, deck.reviewsDocumentUrl]) { if (found.some((other) => isSameUrl(other, url))) continue; - if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) continue; + if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true))) continue; if (await unclaimedAsRead([deck], url)) found.push(url); } return found; @@ -383,7 +398,8 @@ export function createDeckDocumentGuard( async removal(deck: DeckPlace, url: string): Promise<"deletable" | "unclaimed" | "unreadable"> { const instance = instanceOf(deck); if (isDraftDocument(url)) return "unclaimed"; - if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) return "deletable"; + if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true))) + return "deletable"; const dataset = await readToRemove(deck, url); if (dataset === UNREADABLE) return "unreadable"; return (await standing(deck, url, dataset)) === "unclaimed" ? "unclaimed" : "deletable";