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
11 changes: 11 additions & 0 deletions docs/deck-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,17 @@ The library conforms to [shapes.md](shapes.md)'s
`LibraryDeckV6`, each release to its own format's shape. The app
migrates a format-5 release to format 6 in memory.

The app refuses a release read at the guest's pod, or that states it's
there (`@base`). Nor does it read one that links there by an address
in it: a card's picture or what it's about, its license, a topic, its
series or its contact. Either is `releaseLinksGuestPod`, for an
import, an upgrade and a release from a link alike: a guest's copy of
it couldn't be kept
([guest-mode.md](guest-mode.md#what-comes-from-outside)). Text the
reader reads, such as a card's text or what changed in the release,
may name any address. Before it writes anything, an import checks a
release's license, topics and cards for such a link.

A release's cards and distractors share one set of ids: a copy keeps
them all in one cards document, each at its id. The app refuses a
release in which a card and a distractor have the same id, or two
Expand Down
33 changes: 29 additions & 4 deletions docs/guest-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ session), and the deck opens in the guest's instance. A logged-in
user's opens it in their own. A guest's study that can't start leaves the
landing page, with the reason.

## What comes from outside

When the study is kept, every address that names the guest's pod must
be one of the study's own, which the move gives a new place
([Keeping the study](#keeping-the-study)). So data from outside never
brings one in, for a guest and a logged-in user alike:

- a [release](deck-library.md#reading-it), from the library or a link,
is refused when it's at the guest's pod or links to it by an
address in it, such as a card's picture or what it's about
(`releaseLinksGuestPod`);
- so is a release made a [draft](studio.md#drafts), from a file or as
a next version;
- so is a [deck file](studio.md#import-and-export) with such an
address (`deckFileLinksGuestPod`). The deck's, its cards' and their
documents' own addresses may name it: the import gives them new
ones.

Text the reader reads, such as card text and notes or what changed in
a release, may name any address.

## Keeping the study

```mermaid
Expand Down Expand Up @@ -258,10 +279,14 @@ instance goes). It runs these steps:
user's (`ContainerMove.renames`).
3. **adopt:** name the catalog's publisher as the user's profile names
them. No Turtle document of the copy may still name an IRI at the
origin of `GUEST_ORIGIN`, in any spelling (`guestUrlsLeft`, which
names the document). Literals aren't looked at, their text or their
datatypes: the copy moves only the IRIs of subjects, predicates,
objects and graphs, and text such as a card's may name any address.
origin of `GUEST_ORIGIN`, in any spelling. The failure names the
guest's deck the document is of (`guestDeckCopyUrlsLeft`), so it
can be removed from the guest's study: the deck's cards or reviews
document, or the catalog whose entry of it links there. Else it
names the document (`guestUrlsLeft`). Literals aren't looked at,
their text or their datatypes: the copy moves only the IRIs of
subjects, predicates, objects and graphs, and text such as a card's
may name any address.
4. **validate:** the copy must fully conform to the shapes
(`movedCopyInvalid`).
5. **verify:** the guest's instance must be unchanged since it was
Expand Down
12 changes: 10 additions & 2 deletions docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -1461,7 +1461,12 @@ file in a newer format than the app reads is refused (as
`deckFileTooNew`, not `writtenByNewerApp`, since nothing is saved
over), as is one without exactly one deck, or one that doesn't parse.
So is one whose deck has an address that isn't a URL, which its entry
in the catalog can't hold.
in the catalog can't hold. So is one with an address the import keeps,
such as a card's picture or what it's about, that links to the guest's
pod (`deckFileLinksGuestPod`,
[guest-mode.md](guest-mode.md#what-comes-from-outside)). The deck's,
its cards' and their documents' own addresses may: the import gives
them new ones.

**Import into** the instance (`importDeckFile`) makes a new deck of
it, with its progress when the file has some and the user leaves it
Expand Down Expand Up @@ -1599,7 +1604,10 @@ chapter, step and wrong option must be a fragment of the release
(`<release>#id`), as the draft holds only those as its parts. One
anywhere else is shown by no editor and checked by no rule, but would
be published with the draft, so such a release is refused too
(`releaseForeignPart`).
(`releaseForeignPart`). So is one that links to the guest's pod by an
IRI (`releaseLinksGuestPod`): a guest's draft of it couldn't be kept
([guest-mode.md](guest-mode.md#what-comes-from-outside)). Its text may
name any address.

Of a release's other statements, the draft keeps those of the release,
of its own subjects, and of what they name, at any depth: a source, a
Expand Down
10 changes: 7 additions & 3 deletions packages/application/src/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ export interface DeckRepository {
* entry is checked before anything is written, and a failure after
* that deletes the cards document, unless the entry was written after
* all; one that cannot be deleted is reported, as for any import
* (finishCutOffImport in writes.ts).
* (finishCutOffImport in writes.ts). A release whose license, topics
* or cards link to the guest's pod is refused before any write
* (releaseLinksGuestPod, domain/guest.ts linksGuestPod).
*/
importDeck(instanceUrl: string, content: LibraryDeckContent): Promise<Deck>;
/**
Expand Down Expand Up @@ -550,8 +552,10 @@ export interface DeckArchive {
* the text does not parse (or a JSON-LD context would be fetched from
* elsewhere), notADeckFile when it holds no deck this app can read, or
* more than one, deckFileTooNew when a deck, card or review state
* is in a newer format than this app reads, and deckFileTooLarge when
* the file is larger than a deck file may be.
* is in a newer format than this app reads, deckFileTooLarge when
* the file is larger than a deck file may be, and
* deckFileLinksGuestPod when an address it would keep, but its own
* places, links to the guest's pod (domain/guest.ts linksGuestPod).
*/
readDeckFile(text: string, format: DeckFileFormat, baseUrl: string): Promise<DeckFileContent>;
}
Expand Down
44 changes: 44 additions & 0 deletions packages/application/src/releaseDrafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,50 @@ describe("createReleaseDraft", () => {
expect(repository.create).not.toHaveBeenCalled();
});

it("refuses a release that links to the guest's pod, writing nothing, but not one whose text names it (GHSA-hc3p-fpwj-fvjp)", async () => {
const { useCases, repository } = setUp();
const linking = {
...v1,
triples: [
...v1.triples,
{
subject: V1,
predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
object: { kind: "iri" as const, value: "HTTPS://GUEST.solid-memo.invalid:443/x" },
},
],
};
const refused = new AppError("releaseLinksGuestPod", { url: V1 });
repository.readRelease.mockResolvedValueOnce(linking);
await expect(
useCases.createReleaseDraft(INSTANCE, { kind: "nextVersionOf", url: V1 }, WEB_ID),
).rejects.toEqual(refused);
repository.parseRelease.mockResolvedValueOnce(linking);
await expect(
useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID),
).rejects.toEqual(refused);
expect(repository.create).not.toHaveBeenCalled();
const naming = {
...v1,
triples: [
...v1.triples,
{
subject: V1,
predicate: "http://www.w3.org/2000/01/rdf-schema#comment",
object: {
kind: "literal" as const,
value: `See ${GUEST_INSTANCE_URL}`,
language: "",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
},
],
};
repository.parseRelease.mockResolvedValueOnce(naming);
await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID);
expect(repository.create).toHaveBeenCalledOnce();
});

it("refuses a release with a card outside it, writing nothing, but not one of its own its records do not hold", async () => {
const { useCases, repository } = setUp();
const typed = (subject: string, type: string): DraftTriple => ({
Expand Down
7 changes: 5 additions & 2 deletions packages/application/src/releaseDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,13 @@ function placeTaken(error: unknown, url: string): boolean {
* The release, unless it names a subject by an id a draft cannot keep
* (releaseIdUnsupported), or has a card, chapter, step or distractor
* outside it (releaseForeignPart): no editor or check of a draft would
* see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Of
* its other statements, it keeps only its own (ownStatements).
* see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Nor
* one that links to the guest's pod by an IRI (releaseLinksGuestPod): a
* guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Of its
* other statements, it keeps only its own (ownStatements).
*/
function draftable(read: ReleaseDraft): ReleaseDraft {
if (draftAddressesGuest(read)) throw new AppError("releaseLinksGuestPod", { url: read.url });
const id = unsupportedIdOf(read);
if (id !== null) throw new AppError("releaseIdUnsupported", { id });
const release = {
Expand Down
63 changes: 63 additions & 0 deletions packages/application/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,69 @@ describe("guests", () => {
expect(deps.instanceCopier.deleteRecursively).not.toHaveBeenCalled();
});

it("transferGuestStudy names the guest's deck whose copied document still names the guest's pod, so it can be left out", async () => {
const { deps } = guestDeps();
const deckOf = (id: string, extra: Partial<Deck> = {}): Deck => ({
...deck,
id,
url: `${GUEST_INSTANCE_URL}catalog.ttl#${id}`,
title: { en: id },
cardsDocumentUrl: `${GUEST_INSTANCE_URL}decks/${id}.ttl`,
reviewsDocumentUrl: `${GUEST_INSTANCE_URL}reviews/${id}.ttl`,
...extra,
});
const linking = deckOf("deck-l", { license: `${GUEST_ORIGIN}license` });
vi.mocked(deps.deckRepository.listDecks).mockImplementation(async (url) =>
url === GUEST_INSTANCE_URL ? [deckOf("deck-a"), deckOf("deck-b"), linking] : [],
);
vi.mocked(deps.instanceCopier.listResources).mockResolvedValue([
`${GUEST_INSTANCE_URL}catalog.ttl`,
`${GUEST_INSTANCE_URL}decks/deck-a.ttl`,
`${GUEST_INSTANCE_URL}reviews/deck-b.ttl`,
`${GUEST_INSTANCE_URL}history/2026-10.ttl`,
]);
const moveNaming = async (named: string) => {
vi.mocked(deps.instanceCopier.namesIriAt).mockImplementation(async (url) => url === named);
const outcome = await createUseCases(deps).transferGuestStudy(session, guestInstance, {
containerUrl: TARGET,
registrationTarget: "private",
});
return outcome.ok ? null : outcome.error;
};
expect(await moveNaming(`${TARGET}decks/deck-a.ttl`)).toEqual(
new AppError("guestDeckCopyUrlsLeft", {
deck: { en: "deck-a" },
url: `${TARGET}decks/deck-a.ttl`,
}),
);
expect(await moveNaming(`${TARGET}reviews/deck-b.ttl`)).toEqual(
new AppError("guestDeckCopyUrlsLeft", {
deck: { en: "deck-b" },
url: `${TARGET}reviews/deck-b.ttl`,
}),
);
// In the catalog, the deck whose entry links to the guest's pod.
expect(await moveNaming(`${TARGET}catalog.ttl`)).toEqual(
new AppError("guestDeckCopyUrlsLeft", {
deck: { en: "deck-l" },
url: `${TARGET}catalog.ttl`,
}),
);
// A document of no deck, or a catalog whose entries link nowhere there, is named itself.
expect(await moveNaming(`${TARGET}history/2026-10.ttl`)).toEqual(
new AppError("guestUrlsLeft", { url: `${TARGET}history/2026-10.ttl` }),
);
vi.mocked(deps.deckRepository.listDecks).mockResolvedValue([deckOf("deck-a")]);
expect(await moveNaming(`${TARGET}catalog.ttl`)).toEqual(
new AppError("guestUrlsLeft", { url: `${TARGET}catalog.ttl` }),
);
// So is any, when the guest's decks cannot be listed.
vi.mocked(deps.deckRepository.listDecks).mockRejectedValue(new Error("unreadable"));
expect(await moveNaming(`${TARGET}decks/deck-a.ttl`)).toEqual(
new AppError("guestUrlsLeft", { url: `${TARGET}decks/deck-a.ttl` }),
);
});

it("transferGuestStudy refuses a copy that still names the guest's pod, or does not conform", async () => {
const { deps } = guestDeps();
vi.mocked(deps.instanceCopier.namesIriAt).mockImplementation(async (url) =>
Expand Down
29 changes: 28 additions & 1 deletion packages/application/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { profileNameOf, type SolidAccount } from "@solid-memo/domain/account";
import { rebaseIri } from "@solid-memo/domain/instanceUpdate";
import { documentUrlOf } from "@solid-memo/domain/subjectUrl";
import { DECK_FORMAT_VERSION, type Deck } from "@solid-memo/domain/deck";
import type { Instance, RegistrationTarget } from "@solid-memo/domain/instance";
import {
Expand All @@ -29,6 +30,7 @@ import {
guestMergeKey,
guestMergePlan,
isGuestUrl,
linksGuestPod,
mergedAnswer,
mergedDeck,
type GuestMergeOutcome,
Expand Down Expand Up @@ -331,6 +333,31 @@ export function createSessionUseCases({
if ((await instanceRepository.listInstances(GUEST_WEBID)).length === 0)
await guestPod.discard();
}
/**
* The error of a move whose copied document at `url` still names the
* guest's pod: when the document is a guest's deck's, its cards or
* reviews document, or the catalog whose entry of it links to the
* guest's pod, the error names that deck (guestDeckCopyUrlsLeft), so
* the user can tell which to leave out; else the document
* (guestUrlsLeft), as when the guest's decks cannot be listed.
*/
async function guestUrlsLeftIn(
url: string,
guestInstanceUrl: string,
{ from, to }: ContainerMove,
): Promise<AppError> {
const moved = (iri: string) => rebaseIri(iri, from, to);
const decks = await deckRepository.listDecks(guestInstanceUrl).catch((): Deck[] => []);
const deck = decks.find(
(deck) =>
moved(deck.cardsDocumentUrl) === url ||
moved(deck.reviewsDocumentUrl) === url ||
(moved(documentUrlOf(deck.url)) === url && linksGuestPod(deck)),
);
return deck === undefined
? new AppError("guestUrlsLeft", { url })
: new AppError("guestDeckCopyUrlsLeft", { deck: deck.title, url });
}
/**
* Add a guest's deck to the instance as a new deck, whole: its cards
* document, then its reviews document (each created only where nothing
Expand Down Expand Up @@ -594,7 +621,7 @@ export function createSessionUseCases({
progress.stepped();
for (const url of documents) {
if (await instanceCopier.namesIriAt(url, GUEST_ORIGIN))
throw new AppError("guestUrlsLeft", { url });
throw await guestUrlsLeftIn(url, guestInstance.url, move);
progress.stepped();
}
progress.finished("validate");
Expand Down
6 changes: 6 additions & 0 deletions packages/domain/src/appError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export const ERROR_TEMPLATES = {
},
guestUrlsLeft:
"The copy {url} in your Pod still points to the guest's study, so your study is left as it was. Try again.",
guestDeckCopyUrlsLeft:
"The copy of the deck “{deck}” in your Pod still points to the guest's study, so your study is left as it was. Remove that deck from the guest's study, then try again.",
guestDeckUrlsLeft:
"The deck “{deck}” still points to the guest's study, so it was not added to your Pod. Leave it out, then try again.",
guestStudyInvalid: {
Expand Down Expand Up @@ -175,6 +177,8 @@ export const ERROR_TEMPLATES = {
notADeck: "That is not a Solid Memo deck, so it cannot be added. Choose another deck.",
libraryIdsRepeat:
"This deck gives two of its cards or wrong options the same id, so Solid Memo cannot read it. Ask its creator to check it.",
releaseLinksGuestPod:
"This release links to the guest's study in this browser, which no release may, so Solid Memo does not read it. Ask its creator to check it.",
libraryDeckTooNew:
"This deck is in a newer format than this version of Solid Memo can read. Reload the page to get the latest version.",
libraryCardTooNew:
Expand All @@ -183,6 +187,8 @@ export const ERROR_TEMPLATES = {
"Solid Memo cannot read that file. Choose a Turtle or JSON-LD file exported from Solid Memo.",
notADeckFile:
"That file does not hold one Solid Memo deck. Choose a file exported from Solid Memo.",
deckFileLinksGuestPod:
"That file links to the guest's study in this browser, which no deck may, so it was not imported. Choose another file.",
deckFileTooNew:
"That file was written by a newer version of Solid Memo, so this one cannot import it. Reload the page to get the latest version.",
deckFileTooLarge:
Expand Down
Loading
Loading