diff --git a/CHANGES.md b/CHANGES.md index 7534fe2..976f408 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,16 @@ To be released. ### DrFed + - Activities and objects saved by the common write operations are now + served over ActivityPub as they were saved, with their extension + properties and without blind recipients, also inside extension values. + Published activities of every type appear in the actor's outbox beside + existing `Create` activities. Earlier activities keep the versions they + embedded after an edit. A deleted object is answered with a `Tombstone`, + and its `Delete` is the only activity about it that remains retrievable. A + Public activity that embeds content addressed to particular recipients, + even inside an extension value, is not served. Existing IRIs do not change. + [[#153], [#177] by ChanHaeng Lee\] - Added immutable document histories for local object creation and accepted incoming Create activities. Database upgrades retain existing object and activity documents, including those of deleted objects. Back up and stop @@ -28,8 +38,10 @@ To be released. [#135]: https://github.com/fedify-dev/drfed/issues/135 [#137]: https://github.com/fedify-dev/drfed/pull/137 [#150]: https://github.com/fedify-dev/drfed/issues/150 +[#153]: https://github.com/fedify-dev/drfed/issues/153 [#164]: https://github.com/fedify-dev/drfed/pull/164 [#167]: https://github.com/fedify-dev/drfed/pull/167 +[#177]: https://github.com/fedify-dev/drfed/pull/177 ### GraphQL APIs @@ -73,6 +85,16 @@ To be released. revision ID is stale, and object histories can end with a whiteout deletion marker. [[#150], [#164] by Hong Minhee\] + - Added the `@drfed/federation/serving` subpath. It exports + `publicDocument()`, the serialization federation reads use for saved + documents, `parseVocab()`, which parses such a document into a Fedify + vocabulary object, and `loadActivitySnapshot()`. + [[#153], [#177] by ChanHaeng Lee\] + - Added `extensions` to the nodes `describeDocument()` in + `@drfed/models/document` returns: the JSON-LD node objects in their + extension values, with the standard properties their keys stand for, + their types and their IRI references. + [[#153], [#177] by ChanHaeng Lee\] - Added `validateDocument()` in `@drfed/models/document` for parsed ActivityStreams documents. It returns structured errors and compatibility warnings with original nested field and list-entry paths, preserves custom diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e32682..bc21e40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -663,11 +663,17 @@ An inbound delivery that was verified and answered with 2xx is linked to `activities` by the activity IRI it recorded, through `linkInboundActivity`, never by what a listener reports. +The dispatchers serve resources without legacy typed rows from their current +revisions through *src/serving/*, exported as `@drfed/federation/serving`. +The activity dispatchers, outbox pages and outbox counter share the predicates +in *src/visibility.ts*; change them together. See [federation read internals]. + See [inbox persistence internals] for acceptance and idempotence, and [activity delivery internals] for completion reports, receipts and queue observations. [local write internals]: https://drfed.org/internals/local-writes +[federation read internals]: https://drfed.org/internals/federation-reads [inbox persistence internals]: https://drfed.org/internals/inbox-persistence [activity delivery internals]: https://drfed.org/internals/activity-deliveries diff --git a/changes.d/drfed/serve-saved-activities.md b/changes.d/drfed/serve-saved-activities.md new file mode 100644 index 0000000..c999a22 --- /dev/null +++ b/changes.d/drfed/serve-saved-activities.md @@ -0,0 +1,15 @@ +--- +links: + '#153': https://github.com/fedify-dev/drfed/issues/153 + '#177': https://github.com/fedify-dev/drfed/pull/177 +--- + - Activities and objects saved by the common write operations are now + served over ActivityPub as they were saved, with their extension + properties and without blind recipients, also inside extension values. + Published activities of every type appear in the actor's outbox beside + existing `Create` activities. Earlier activities keep the versions they + embedded after an edit. A deleted object is answered with a `Tombstone`, + and its `Delete` is the only activity about it that remains retrievable. A + Public activity that embeds content addressed to particular recipients, + even inside an extension value, is not served. Existing IRIs do not change. + [[#153], [#177] by ChanHaeng Lee] diff --git a/changes.d/javascript/federation-serving.md b/changes.d/javascript/federation-serving.md new file mode 100644 index 0000000..5daa199 --- /dev/null +++ b/changes.d/javascript/federation-serving.md @@ -0,0 +1,16 @@ +--- +links: + '#153': https://github.com/fedify-dev/drfed/issues/153 + '#177': https://github.com/fedify-dev/drfed/pull/177 +--- + - Added the `@drfed/federation/serving` subpath. It exports + `publicDocument()`, the serialization federation reads use for saved + documents, `parseVocab()`, which parses such a document into a Fedify + vocabulary object, and `loadActivitySnapshot()`. + [[#153], [#177] by ChanHaeng Lee] + + - Added `extensions` to the nodes `describeDocument()` in + `@drfed/models/document` returns: the JSON-LD node objects in their + extension values, with the standard properties their keys stand for, + their types and their IRI references. + [[#153], [#177] by ChanHaeng Lee] diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8aae71e..1d9a1c7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -18,6 +18,16 @@ import { defineConfig } from "vitepress"; import { forEngine } from "../../scripts/images/engines.mts"; +// A ghcr.io/fedify-dev/drfed image tag in the documentation. +const IMAGE_REFERENCE = /\bghcr\.io\/fedify-dev\/drfed:\d[0-9A-Za-z.-]*/gu; + +// Star-orbit symbol from https://github.com/hackers-pub/visual-identity +// (exports/starorbit-transparent-17x12.svg), © 2025 Bak Eunji, +// CC BY-SA 4.0 . +// Modified: recolored to currentColor and stripped of IDs and clip paths. +const HACKERS_PUB_ICON = + 'hackers.pubStar-orbit symbol by Bak Eunji (hackers-pub/visual-identity), CC BY-SA 4.0, recolored to currentColor'; + // A link to the other deployment (the release site from canary and the other // way around), set at build time. Neither variable set means no extra link; // setting only one of them is a misconfigured deployment and fails the build. @@ -48,23 +58,15 @@ if ( `DRFED_IMAGE_VERSION must be a published version; got ${JSON.stringify(DRFED_IMAGE_VERSION)}.`, ); } -const imageReference = /\bghcr\.io\/fedify-dev\/drfed:\d[0-9A-Za-z.-]*/gu; function publishedImage(text: string): string { return DRFED_IMAGE_VERSION === undefined ? text : text.replaceAll( - imageReference, + IMAGE_REFERENCE, `ghcr.io/fedify-dev/drfed:${DRFED_IMAGE_VERSION}`, ); } -// Star-orbit symbol from https://github.com/hackers-pub/visual-identity -// (exports/starorbit-transparent-17x12.svg), © 2025 Bak Eunji, -// CC BY-SA 4.0 . -// Modified: recolored to currentColor and stripped of IDs and clip paths. -const HACKERS_PUB_ICON = - 'hackers.pubStar-orbit symbol by Bak Eunji (hackers-pub/visual-identity), CC BY-SA 4.0, recolored to currentColor'; - export default defineConfig({ lang: "en-US", title: "DrFed", @@ -165,6 +167,7 @@ export default defineConfig({ { text: "Runtime lifecycle", link: "/internals/runtime-lifecycle" }, { text: "Inbox persistence", link: "/internals/inbox-persistence" }, { text: "Local writes", link: "/internals/local-writes" }, + { text: "Federation reads", link: "/internals/federation-reads" }, { text: "Activity deliveries", link: "/internals/activity-deliveries", diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md new file mode 100644 index 0000000..0e4702a --- /dev/null +++ b/docs/internals/federation-reads.md @@ -0,0 +1,166 @@ +Federation reads +================ + +The federation package serves local resources through the object dispatchers +in *packages/federation/src/object-dispatchers.ts* and the outbox dispatcher in +*packages/federation/src/collection.ts*. Both read through +*packages/federation/src/serving/*, and share the SQL predicates in +*packages/federation/src/visibility.ts*, so that a resource is served the same +way wherever it is read. + + +Routes +------ + +| Route | Serves | +| ----------------------- | --------------------------------------------------------- | +| `/users/{actor}/{id}` | Objects attributed to the actor, including a `Question` | +| `/ap/creates/{id}` | `Create` activities | +| `/ap/activities/{id}` | Every other activity, including a `Question` saved as one | +| `/users/{actor}/outbox` | The actor's published activities | + +The common write operations allocate an IRI from the route of the resource's +type, so a route answers 404 for a resource allocated to another one. No IRI +changes: legacy `Create` activities stay at `/ap/creates/{id}`. + + +Sources +------- + +A `Note` or `Article` created by the `createObject` mutation, and its `Create`, +have legacy typed rows and are serialized from them by `toObject()` and +`toCreate()`, as before, also after an edit keeps the rows in step. + +Every other resource, saved by the [common write operations](local-writes.md), +is served from the document of its current revision: + + - The document is served as saved, with its context, extension properties, + IRI references and embedded values. An activity keeps the version it + embedded when it was saved, so editing an object does not change an + earlier activity's response. Nothing is dereferenced. + - `bto` and `bcc` are removed from the root, from every embedded + document, and from every JSON-LD node object in extension values, which + a JSON-LD processor reads with the context it inherits. They are found + by meaning through `describeDocument()`, so aliases of them are removed + as well; in extension values, whatever the alias's container. JSON + literals stay as saved, however their keywords are spelled. + - A document saved without `@context` states the ActivityStreams context, + which the validator read it with and which Activity Streams 2.0 asks + documents to include. + - The document must name its own resource in `id`. Fedify does not parse + it: the dispatchers hand Fedify a vocabulary object whose JSON-LD is the + document, so a document with values Fedify 2.4.3 cannot read, such as a + `Link` in `audience` or `object`, is served as saved too. + - A document the write path does not save, one the validator cannot read + or one naming another resource, can only come from a change made outside + it. It is logged under `["drfed", "federation", "serving"]` and left + out: its resource answers 404, and outbox pages and the counter skip it. + Each revision is logged once per process, however often it is read, + while it is among the last 4,096 logged. + + +Conditions +---------- + +Every read needs the request's authority to be the instance of a live local +actor, and Public `to` or `cc` addressing in the resource's current +`addressing` index. Moreover: + + - An activity must be in the outbox of such an actor. A saved activity that + was not published is not served, nor is a received one. + - A legacy `Create` is not served once its object is deleted. A saved + document is not served when the `object` of its root or of an embedded + activity refers to a deleted local object, unless that activity is a + `Delete`: the deletion hides the `Like` and other activities about the + object, and an `Undo` of such a `Like`, and leaves its `Delete` + retrievable, also inside an `Announce` that embeds it. A local object is + deleted when its typed row is, or when its current revision is a local + whiteout. + - A saved document that embeds a deleted local object, at any depth, is + not served, so the `Create` and `Update` activities that carried its + content, and an `Undo` of a `Like` embedding it, are hidden. + - A saved document that embeds, at any depth, a document addressed to + recipients but not to the public in its `to` or `cc` is not served, + since its content is theirs. Its recipients are read as the + `addressing` index records them: references as resolved, embedded + documents by `id`, and `Link` values by `href`. An embedded document + without recipients of its own goes with the document that embeds it, as + the `Like` in an `Undo` usually does. + - These rules read the node objects in extension values as embedded + documents, with their values through `@set` and `@list` objects and + their recipients by `href` when a type is `Link`, and by `id` + otherwise. A node object whose `object` refers to a deleted + local object, by a reference or a `href`, hides the document unless its + type is `Delete`. A saved document is not served when a node object in + an extension value is one `describeDocument()` leaves uninterpreted: one + with a `@context` of its own, which the validator does not read, or with + a value object, a language map, a string its key does not coerce to an + IRI, or a relative reference where a reference belongs, more than one + `id` or `href`, properties nested by `@nest` or reversed by `@reverse`, + a value or a list object with keys other than keywords, or with keys a + context of its own may alias, or a value that is an object but no JSON + literal, since its recipients and references cannot then be told. A + blank node identifier is read as an `id` that names no resource, but + not as a reference, since it may name a node object defined apart. A + node object whose `id` is used elsewhere in the document, as another + node object's `id` or as a reference outside its own properties, is one + node with what uses it, and is not read apart either; a `Link` whose + `href` is its own `id` is read. Language maps are read as strings, and + an object in one, which is no valid JSON-LD, is not read; a list under + a key with a `@language` container is no language map, and its node + objects are read. + - Embedded documents that share an `id` are one document, which none of + them tells, so a saved document with two is not served either. An + `object` reference names the `href` of a `Link` the document embeds + elsewhere with that `id` as well. Such documents are logged once per + revision as a warning. + - An object must be attributed to the actor of its route by its last + snapshot. A deleted object is answered with `410 Gone` and a `Tombstone` + of its `id` and `deleted` time only. + +What a saved document embeds and refers to is read from the document, through +`describeDocument()`; every other condition is SQL. + + +Outbox +------ + +Outbox pages hold legacy and saved activities in one order: ascending +`position` of the outbox's `collection_items`, where the newest item has the +smallest position. An item without a position is not listed, and its activity +is not served either. The cursor is the position of the last item of the +previous page. + +The saved activities their documents do not allow are left out after rows are +selected, so a page reads further batches until it is full, each twice as +large as the one before, up to 1,000 items. It promises a next page only when +a served activity follows, so only the last page is short and no page is empty +while another follows. A page after many hidden activities therefore reads +all of them. + +The counter counts what the pages hold. It reads the document of every saved +activity in the outbox on each request, 100 at a time, so its cost grows with +the outbox. Deleted objects are looked up 1,000 IRIs at a time, below the +number of parameters PostgreSQL binds in one statement. + + +Outgoing payloads +----------------- + +`@drfed/federation/serving` exports the same serialization for outgoing +payloads: + + - `publicDocument(document)` returns a copy without blind recipients and + with a stated context, or `null` for a document the validator cannot + read offline or whose blind recipients cannot be found, as in a node + object with a `@context` of its own in an extension value. + - `parseVocab(ctx, document)` parses a document into a vocabulary object + that keeps it as its JSON-LD. It throws for a document Fedify cannot + read, such as one with a `Link` in `audience` or `object`. + - `loadActivitySnapshot(db, id)` loads a saved activity's current document, + blind recipients included, and its revision ID, applying no condition + above. + +An object derived from a parsed one, as by `clone()`, loses that JSON-LD and is +serialized from the properties Fedify knows, without extensions. Delivery is +not part of these reads. diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index 9b1b5a1..476905e 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -99,8 +99,8 @@ Identity and documents New resources get UUIDv7 IRIs from Fedify's router: objects at `/users/{actor}/{id}`, `Create` at `/ap/creates/{id}`, and every other activity -at `/ap/activities/{id}`, which answers 404 until #153 serves saved -activities. A new resource's `id`, its author (`actor` for an activity, +at `/ap/activities/{id}`, where [federation reads](federation-reads.md) serve +them. A new resource's `id`, its author (`actor` for an activity, `attributedTo` for an object) and `published` are server-owned values given to `prepareDocument()`; a submitted conflicting value is a `server_owned_field_conflict`, never silently replaced. An edit owns `id`, @@ -109,15 +109,18 @@ without one cannot gain one. Every other identity claim is checked by meaning, through `describeDocument()`, so aliases, full-IRI keys, `@id` aliases and compact IRIs -cannot hide it. An `actor` or `attributedTo` value on a DrFed instance host must -be an actor of the acting actor's instance, and an `id` there must be a -registered resource of a known kind or one this write allocates. An IRI -registered only because a document addressed or referred to it has the -`unknown` kind and does not count. Other values are -`unauthorized_local_identity` at their source paths. Remote identities are not -checked; copying a remote representation proves nothing about it. Plain -references, such as `object` or `target` IRIs, never request a write and are -never fetched or modified. +cannot hide it, also in the node objects of extension values, which +[federation reads](federation-reads.md) serve as well. An `actor` or +`attributedTo` value on a DrFed instance host, including the `id` of a document +or the `href` of a `Link` embedded there, and the `href` of a `Link` embedded +elsewhere with the `id` a value names, must be an actor of the acting actor's +instance, and an `id` there must be a registered resource of a known +kind or one this write allocates. An IRI registered only because a document +addressed or referred to it has the `unknown` kind and does not count. Other +values are `unauthorized_local_identity` at their source paths. Remote +identities are not checked; copying a remote representation proves nothing +about it. Plain references, such as `object` or `target` IRIs, never request a +write and are never fetched or modified. Resource descriptors name the root, `[]`, and optionally embedded documents reached through `object`, `target`, `origin`, `result` or `instrument` of @@ -131,11 +134,11 @@ parent keeps it embedded, as submitted, or refers to it by IRI with Each saved document gets revision addressing and a current `addressing` index from its semantic `to`, `cc`, `bto`, `bcc` and `audience`, each value at its source position: IRIs as resolved, embedded documents by `id`, `Link` values by -`href`, and anonymous values without a row. Relations index `actor`, `object`, -`target`, `origin`, `result`, `instrument` and `attributedTo`. A document that -spells one of these properties in several ways is refused, since its index -would otherwise leave occurrences out. Indexes stay `partial`; the documents -remain authoritative. +`href`, also written as a list of one value, and anonymous values without a row. +Relations index `actor`, `object`, `target`, `origin`, `result`, `instrument` +and `attributedTo`. A document that spells one of these properties in several +ways is refused, since its index would otherwise leave occurrences out. Indexes +stay `partial`; the documents remain authoritative. Edits and deletions @@ -154,8 +157,8 @@ describes the last content. The generated `Delete` refers to the object by IRI and copies the last version's addressing under its context. Resources created by the common write have revisions only, so GraphQL details -and federation responses ignore them until #153 and #154. Objects created by -the `createObject` mutation also have legacy typed rows that serving reads; +ignore them until #154; federation reads serve their documents. Objects created +by the `createObject` mutation also have legacy typed rows that serving reads; `writeTypedResourceRevision()` keeps those rows in step. Before writing, the served columns must agree with the current revision, or the object is refused as drifted. An edited version must fit the typed columns without loss: a `Note` diff --git a/docs/internals/resource-revisions.md b/docs/internals/resource-revisions.md index e089609..5a72060 100644 --- a/docs/internals/resource-revisions.md +++ b/docs/internals/resource-revisions.md @@ -209,20 +209,21 @@ new index does not expose historical content. Application boundaries ---------------------- -The current editor, lists and federation serializers still use legacy -`Note`/`Create` projections. A generic canonical resource can have no typed -detail; its GraphQL `Resource.detail` is then `null`. The common write +The current editor and lists still use legacy `Note`/`Create` projections. A +generic canonical resource can have no typed detail; its GraphQL +`Resource.detail` is then `null`. [Federation reads](federation-reads.md) serve +such a resource from its current revision. The common write operations described in [local writes](local-writes.md) save new resources as revisions only and keep legacy typed objects in step when editing or deleting -them. Historical authorization and generalized federation surfaces are separate +them. Historical authorization and the other federation surfaces are separate work. Appending a whiteout does not create or send a `Delete` activity. The common delete operation appends it together with the `Delete`, outbox entry and the caller's durable dispatch reservation in one transaction; sending happens -asynchronously. Generic `Tombstone` serving with `410 Gone` belongs to the -generalized federation integration. Dispatch must freeze its own outgoing -document; retained revisions are not wire payloads. +asynchronously. Federation reads serve a whiteout as a `Tombstone` with +`410 Gone`. Dispatch must freeze its own outgoing document; retained revisions +are not wire payloads. Storage tests cover fixtures for 30 activity types, including `Activity` and `IntransitiveActivity`, and the `Object` base. They include nested diff --git a/fixtures/document-validation/initial.json b/fixtures/document-validation/initial.json index 24aafc8..3f4f6f2 100644 --- a/fixtures/document-validation/initial.json +++ b/fixtures/document-validation/initial.json @@ -1,7 +1,7 @@ { "status": "draft", "entryPoint": "validateDocument", - "description": "Validation expectations; all cases are executed by document.test.ts. Diagnostic categories and severities are defined by diagnosticReasons in @drfed/models/document. Unspecified changes default to []; unspecified normalizedDocument is absent. Every case also requires unchanged input and no network or database operations.", + "description": "Validation expectations; all cases are executed by document.test.ts. Diagnostic categories and severities are defined by DIAGNOSTIC_REASONS in @drfed/models/document. Unspecified changes default to []; unspecified normalizedDocument is absent. Every case also requires unchanged input and no network or database operations.", "fixtures": [ { "id": "context-empty-term", diff --git a/packages/federation/README.md b/packages/federation/README.md index ccef7d9..8edc3e1 100644 --- a/packages/federation/README.md +++ b/packages/federation/README.md @@ -47,6 +47,7 @@ Exports | `@drfed/federation/local-write` | Common write operations that save, edit, and delete resources | | `@drfed/federation/object` | Query selections, `toObject()`, and `toCreate()` | | `@drfed/federation/origin` | `instanceHost()`, `classifyHost()`, and other host helpers | +| `@drfed/federation/serving` | Serialization of saved documents for public output | The serializers in `@drfed/federation/object` are the same ones the dispatchers use, so documents stored by GraphQL mutations match what is served @@ -69,6 +70,19 @@ checking permission again. Saving never delivers anything. See the [local write internals]: https://drfed.org/internals/local-writes +Federation reads +---------------- + +The dispatchers serve legacy `Note`/`Article` objects and `Create` activities +from their typed rows, and resources saved by the common write operations from +their current documents, without blind recipients. `publicDocument()` from +`@drfed/federation/serving` is the same serialization, for outgoing payloads +to share, and `parseVocab()` parses its result into a Fedify vocabulary +object. See [federation read internals] for what is served where. + +[federation read internals]: https://drfed.org/internals/federation-reads + + Activity deliveries ------------------- @@ -116,6 +130,7 @@ transition. Do not delete the row merely to trigger first-use generation. Logging ------- -Inbox activity is logged under the `["drfed", "federation"]` category, and +Inbox activity is logged under the `["drfed", "federation"]` category, activity delivery recording under -`["drfed", "federation", "activity-delivery"]`. +`["drfed", "federation", "activity-delivery"]`, and saved documents that +federation reads cannot serve under `["drfed", "federation", "serving"]`. diff --git a/packages/federation/package.json b/packages/federation/package.json index ac4526a..22fb75a 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -69,6 +69,10 @@ "./task-queue": { "types": "./dist/task-queue.d.mts", "default": "./dist/task-queue.mjs" + }, + "./serving": { + "types": "./dist/serving.d.mts", + "default": "./dist/serving.mjs" } }, "files": [ @@ -83,7 +87,8 @@ "src/local-write.ts", "src/origin.ts", "src/actor-key.ts", - "src/task-queue.ts" + "src/task-queue.ts", + "src/serving.ts" ], "dts": { "sourcemap": true, diff --git a/packages/federation/src/collection.ts b/packages/federation/src/collection.ts index 0b0eabb..e0e337a 100644 --- a/packages/federation/src/collection.ts +++ b/packages/federation/src/collection.ts @@ -14,15 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { type Database, schema } from "@drfed/models"; -import { type Uuid, validateUuid } from "@drfed/models/uuid"; +import type { Database } from "@drfed/models"; +import type { Uuid } from "@drfed/models/uuid"; import type { Context, FederationBuilder } from "@fedify/fedify"; import { Object as APObject } from "@fedify/vocab"; -import { and, eq, sql } from "drizzle-orm"; import { findActiveActor } from "./actor.ts"; -import { activitySelection, toCreate } from "./object.ts"; -import { servedActivity } from "./visibility.ts"; +import { countOutbox, loadOutboxPage } from "./serving/resource.ts"; const OUTBOX_PAGE_SIZE = 20; @@ -40,39 +38,22 @@ export function registerCollectionDispatchers( .setOutboxDispatcher( "/users/{identifier}/outbox", async (ctx, identifier, cursor) => { - if ((await findActiveActor(db, ctx, identifier)) == null) return null; - const boundary = parseOutboxCursor(cursor); - if (boundary === false) return null; - const rows = await db.query.activities.findMany({ - where: { - actorId: identifier as Uuid, - RAW: (table) => - and( - servedActivity(table), - boundary == null - ? undefined - : sql`(${table.published}, ${table.id}) < - (${boundary.published}::timestamptz, ${boundary.id}::uuid)`, - )!, - }, - // Backfilled activity IDs are UUIDv7, so only publication time - // determines chronology. Keep full database precision in cursors. - extras: { - cursorPublished: (table) => - sql`to_char(${table.published} AT TIME ZONE 'UTC', - 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, - }, - orderBy: { published: "desc", id: "desc" }, - limit: OUTBOX_PAGE_SIZE + 1, - with: activitySelection, - }); - const page = rows.slice(0, OUTBOX_PAGE_SIZE); + const actor = await findActiveActor(db, ctx, identifier); + if (actor == null) return null; + const after = parseOutboxCursor(cursor); + if (after === false) return null; + const outboxId = outboxOf(actor); + if (outboxId == null) return { items: [] }; + const page = await loadOutboxPage( + db, + ctx, + outboxId, + after, + OUTBOX_PAGE_SIZE, + ); return { - items: page.map((object) => toCreate(ctx, object)), - nextCursor: - rows.length > OUTBOX_PAGE_SIZE - ? `${page.at(-1)!.cursorPublished}|${page.at(-1)!.id}` - : null, + items: [...page.items], + nextCursor: page.nextCursor == null ? null : String(page.nextCursor), }; }, ) @@ -81,15 +62,9 @@ export function registerCollectionDispatchers( ) .setCounter(async (ctx, identifier) => { const actor = await findActiveActor(db, ctx, identifier); - return actor == null - ? null - : db.$count( - schema.activities, - and( - eq(schema.activities.actorId, actor.id), - servedActivity(schema.activities), - ), - ); + if (actor == null) return null; + const outboxId = outboxOf(actor); + return outboxId == null ? 0 : await countOutbox(db, outboxId); }); builder @@ -153,30 +128,32 @@ export function registerCollectionDispatchers( } /** - * Parse an opaque boundary without rounding database microseconds. - * @returns The boundary, null for the first page, or false for invalid input. + * Reads the outbox collection an actor declares. + * @returns The collection ID, or `null` when it declares none. + */ +function outboxOf(actor: { + readonly collectionReferences: readonly { + readonly role: string; + readonly collectionId: Uuid; + }[]; +}): Uuid | null { + return ( + actor.collectionReferences.find(({ role }) => role === "outbox") + ?.collectionId ?? null + ); +} + +/** + * Parses an outbox cursor: the position of the last item of the previous + * page. + * @returns The position, null for the first page, or false for invalid input. */ -function parseOutboxCursor( - cursor: string | null, -): { published: string; id: string } | null | false { +function parseOutboxCursor(cursor: string | null): number | null | false { if (cursor == null || cursor === "") return null; - const [published, id, extra] = cursor.split("|"); - if ( - published == null || - published.startsWith("0000-") || - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/u.test(published) || - id == null || - !validateUuid(id) || - extra != null - ) { - return false; - } - try { - Temporal.Instant.from(published); - return { published, id }; - } catch { - return false; - } + if (!/^-?\d{1,10}$/u.test(cursor)) return false; + const position = Number(cursor); + // Positions are PostgreSQL integers. + return position >= -(2 ** 31) && position < 2 ** 31 ? position : false; } async function actorCollection( diff --git a/packages/federation/src/federation.test.ts b/packages/federation/src/federation.test.ts index ba006d6..9b82c40 100644 --- a/packages/federation/src/federation.test.ts +++ b/packages/federation/src/federation.test.ts @@ -48,32 +48,32 @@ import { seedRemoteActor, } from "./seed.test.ts"; -const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; +const ACTOR_IRI = `https://test-instance.drfed.org/users/${localActorId}`; +const ACCEPT = { accept: "application/activity+json" }; +const ORIGIN = new URL("https://drfed.test"); +const ACTIVITY_JSON = "application/activity+json"; + const createIri = (id: string) => `https://test-instance.drfed.org/ap/creates/${id}`; -const accept = { accept: "application/activity+json" }; function values(id: string) { return { id: id as Uuid, activityId: uuid(), actorId: localActorId as Uuid, - iri: `${actorIri}/${id}`, + iri: `${ACTOR_IRI}/${id}`, type: "Note" as const, contentHtml: "

Hello

", }; } -const origin = new URL("https://drfed.test"); -const activityJson = "application/activity+json"; - describe("createFederation()", () => { it("registers the actor URI layout", async () => { await withTemporaryDatabase(async (db) => { const federation = await createFederation(db, { kv: new MemoryKvStore(), }); - const ctx = federation.createContext(origin, undefined); + const ctx = federation.createContext(ORIGIN, undefined); assert.equal( ctx.getObjectUri(APObject, { identifier: "a", id: "b" }).href, "https://drfed.test/users/a/b", @@ -120,11 +120,11 @@ describe("createFederation()", () => { // `demo.drfed.test`. Without canonicalizing the lookup key, every actor // on that instance answers 404 to such a request. await withTemporaryDatabase(async (db) => { + const BASE = "https://demo.drfed.test"; const localInstanceId = uuid(); const instanceId = uuid(); const localId = uuid(); const actorId = uuid(); - const base = "https://demo.drfed.test"; await db.insert(schema.localInstances).values({ id: localInstanceId, slug: "demo", @@ -142,8 +142,8 @@ describe("createFederation()", () => { type: "Person", username: "alice", instanceId, - iri: `${base}/users/${actorId}`, - inboxUrl: `${base}/users/${actorId}/inbox`, + iri: `${BASE}/users/${actorId}`, + inboxUrl: `${BASE}/users/${actorId}/inbox`, }); const federation = await createFederation(db, { @@ -153,7 +153,7 @@ describe("createFederation()", () => { const response = await federation.fetch( // HTTP, so that `URL` keeps a port of 443 instead of eliding it. new Request(`http://${host}/users/${actorId}`, { - headers: { accept: activityJson }, + headers: { accept: ACTIVITY_JSON }, }), { contextData: undefined, @@ -226,7 +226,7 @@ describe("createFederation()", () => { const names = []; for (const federation of federations) { const response = await federation.fetch( - new Request(actorIri, { headers: accept }), + new Request(ACTOR_IRI, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); @@ -257,7 +257,7 @@ describe("ActivityPub resource origin spelling", () => { resource === "Create" ? createIri(object.activityId) : object.iri; const requestIri = new URL(new URL(iri).pathname, requestOrigin); const response = await federation.fetch( - new Request(requestIri, { headers: accept }), + new Request(requestIri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, resource === "Tombstone" ? 410 : 200); @@ -286,7 +286,7 @@ describe("ActivityPub objects", () => { ...object, addressing: { [publicProperty]: [PUBLIC_IRI], - [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + [publicProperty === "to" ? "cc" : "to"]: [`${ACTOR_IRI}/followers`], }, language: "ko-KR", name: "Title", @@ -294,14 +294,14 @@ describe("ActivityPub objects", () => { sensitive: true, }); const response = await federation.fetch( - new Request(object.iri, { headers: accept }), + new Request(object.iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.type, "Note"); assert.equal(body.id, object.iri); - assert.equal(body.attributedTo, actorIri); + assert.equal(body.attributedTo, ACTOR_IRI); assert.equal(body.content, object.contentHtml); assert.deepEqual(body.contentMap, { "ko-kr": object.contentHtml }); assert.equal(body.name, "Title"); @@ -311,11 +311,11 @@ describe("ActivityPub objects", () => { assert.ok(body.updated); assert.equal( body.to, - publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, + publicProperty === "to" ? "as:Public" : `${ACTOR_IRI}/followers`, ); assert.equal( body.cc, - publicProperty === "to" ? `${actorIri}/followers` : "as:Public", + publicProperty === "to" ? `${ACTOR_IRI}/followers` : "as:Public", ); }); }); @@ -335,7 +335,7 @@ describe("ActivityPub objects", () => { deleted, }); const response = await federation.fetch( - new Request(object.iri, { headers: accept }), + new Request(object.iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 404); @@ -348,7 +348,7 @@ describe("ActivityPub objects", () => { const object = values(uuid()); await seedObjects(db, { ...object, type: "Article" }); const response = await federation.fetch( - new Request(object.iri, { headers: accept }), + new Request(object.iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal((await response.json()).type, "Article"); @@ -358,7 +358,7 @@ describe("ActivityPub objects", () => { .set({ deleted: deletedAt }) .where(eq(schema.objects.id, object.id)); const deleted = await federation.fetch( - new Request(object.iri, { headers: accept }), + new Request(object.iri, { headers: ACCEPT }), { contextData: undefined }, ); // Fedify serves object tombstones with HTTP 410, keeping the body. @@ -396,16 +396,16 @@ describe("ActivityPub objects", () => { } const iri = scenario === "missing" - ? `${actorIri}/${uuid()}` + ? `${ACTOR_IRI}/${uuid()}` : scenario === "malformed" - ? `${actorIri}/bad` + ? `${ACTOR_IRI}/bad` : scenario === "host" ? object.iri.replace("test-instance.drfed.org", "wrong.example") : scenario === "actor" || scenario === "remote" ? object.iri.replace(localActorId, remoteActorId) : object.iri; const response = await federation.fetch( - new Request(iri, { headers: accept }), + new Request(iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 404); @@ -424,11 +424,11 @@ describe("ActivityPub Create activities", () => { ...object, addressing: { [publicProperty]: [PUBLIC_IRI], - [publicProperty === "to" ? "cc" : "to"]: [`${actorIri}/followers`], + [publicProperty === "to" ? "cc" : "to"]: [`${ACTOR_IRI}/followers`], }, }); const response = await federation.fetch( - new Request(createIri(object.activityId), { headers: accept }), + new Request(createIri(object.activityId), { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); @@ -445,10 +445,12 @@ describe("ActivityPub Create activities", () => { { type: "Create", id: createIri(object.activityId), - actor: actorIri, + actor: ACTOR_IRI, object: object.iri, - to: publicProperty === "to" ? "as:Public" : `${actorIri}/followers`, - cc: publicProperty === "to" ? `${actorIri}/followers` : "as:Public", + to: + publicProperty === "to" ? "as:Public" : `${ACTOR_IRI}/followers`, + cc: + publicProperty === "to" ? `${ACTOR_IRI}/followers` : "as:Public", }, ); assert.ok(body.published); @@ -473,7 +475,7 @@ describe("ActivityPub Create activities", () => { actorId: scenario === "remote" ? remoteActorId : localActorId, addressing: scenario === "followers" - ? { to: [`${actorIri}/followers`] } + ? { to: [`${ACTOR_IRI}/followers`] } : { to: [PUBLIC_IRI] }, deleted: scenario === "deleted" ? Temporal.Now.instant() : null, }); @@ -490,7 +492,7 @@ describe("ActivityPub Create activities", () => { ? createIri("bad") : createIri(object.activityId); const response = await federation.fetch( - new Request(iri, { headers: accept }), + new Request(iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 404); @@ -508,7 +510,7 @@ describe("ActivityPub Create activities", () => { "test-instance.drfed.org", "wrong.example", ), - { headers: accept }, + { headers: ACCEPT }, ), { contextData: undefined }, ); @@ -518,11 +520,11 @@ describe("ActivityPub Create activities", () => { }); describe("ActivityPub general activities", () => { - it("allocates /ap/activities/ IRIs without serving them yet", async () => { + it("does not serve unknown /ap/activities/ IRIs", async () => { await withFederation(async ({ federation }) => { const response = await federation.fetch( new Request(`https://test-instance.drfed.org/ap/activities/${uuid()}`, { - headers: accept, + headers: ACCEPT, }), { contextData: undefined }, ); @@ -544,25 +546,25 @@ describe("ActivityPub outbox", () => { activityId: activityIds[index]!, addressing: index === 22 - ? { to: [`${actorIri}/followers`] } + ? { to: [`${ACTOR_IRI}/followers`] } : index === 20 - ? { to: [`${actorIri}/followers`], cc: [PUBLIC_IRI] } + ? { to: [`${ACTOR_IRI}/followers`], cc: [PUBLIC_IRI] } : { to: [PUBLIC_IRI] }, deleted: index === 21 ? Temporal.Now.instant() : null, })), ); const fetchJson = async (iri: string) => { const response = await federation.fetch( - new Request(iri, { headers: accept }), + new Request(iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); return await response.json(); }; - const collection = await fetchJson(`${actorIri}/outbox`); + const collection = await fetchJson(`${ACTOR_IRI}/outbox`); assert.equal(collection.type, "OrderedCollection"); assert.equal(collection.totalItems, 21); - const page = await fetchJson(`${actorIri}/outbox?cursor=`); + const page = await fetchJson(`${ACTOR_IRI}/outbox?cursor=`); assert.equal(page.orderedItems.length, 20); const activity = page.orderedItems[0]; assert.deepEqual( @@ -577,9 +579,9 @@ describe("ActivityPub outbox", () => { { type: "Create", id: createIri(activityIds[20]!), - actor: actorIri, + actor: ACTOR_IRI, object: values(ids[20]!).iri, - to: `${actorIri}/followers`, + to: `${ACTOR_IRI}/followers`, cc: "as:Public", }, ); @@ -588,18 +590,20 @@ describe("ActivityPub outbox", () => { assert.equal(last.orderedItems[0].object, values(ids[0]!).iri); assert.equal(last.next, undefined); const bad = await federation.fetch( - new Request(`${actorIri}/outbox?cursor=bad`, { headers: accept }), + new Request(`${ACTOR_IRI}/outbox?cursor=bad`, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(bad.status, 404); - for (const published of [ - "0000-01-01T00:00:00.000000Z", - "2026-02-30T00:00:00.000000Z", + for (const cursor of [ + encodeURIComponent(`2026-09-15T00:00:00.000000Z|${uuid()}`), + "1.5", + "2147483648", + "-2147483649", + "--1", ]) { - const cursor = encodeURIComponent(`${published}|${uuid()}`); const invalid = await federation.fetch( - new Request(`${actorIri}/outbox?cursor=${cursor}`, { - headers: accept, + new Request(`${ACTOR_IRI}/outbox?cursor=${cursor}`, { + headers: ACCEPT, }), { contextData: undefined }, ); @@ -607,49 +611,60 @@ describe("ActivityPub outbox", () => { } }); }); - it("orders UUIDv4 backfills by publication and retains microseconds across pages", async () => { + it("orders pages by outbox position, whatever the IDs and publication times", async () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db); const objects = Array.from({ length: 24 }, () => uuid()); + // Old migrated activities have random UUIDv4 IDs larger than UUIDv7. + const activityIds = objects.map((_, index) => + index === 23 + ? uuid() + : (`ffffffff-ffff-4fff-8fff-${String(24 - index).padStart(12, "0")}` as Uuid), + ); + // The outbox migration backfilled positions from publication times; + // these deliberately oppose both the IDs and the insertion order. + const rank = (index: number) => (index * 7) % 24; for (const [index, id] of objects.entries()) { - // Old migrated activities have random UUIDv4 IDs larger than UUIDv7. - // Reverse IDs deliberately oppose publication order within a page. - const activityId = - index === 23 - ? uuid() - : (`ffffffff-ffff-4fff-8fff-${String(24 - index).padStart(12, "0")}` as Uuid); - await seedObjects(db, { ...values(id), activityId }); - const published = `2026-09-15T00:00:00.${String(Math.floor(index / 2)).padStart(6, "0")}Z`; + await seedObjects(db, { + ...values(id), + activityId: activityIds[index]!, + }); await db .update(schema.activities) - .set({ published: sql`${published}::timestamptz` }) - .where(eq(schema.activities.id, activityId)); + .set({ published: sql`'2026-09-15T00:00:00Z'::timestamptz` }) + .where(eq(schema.activities.id, activityIds[index]!)); + await db + .update(schema.collectionItems) + .set({ position: -1 - rank(index) }) + .where(eq(schema.collectionItems.itemId, activityIds[index]!)); } const fetchJson = async (iri: string) => { const response = await federation.fetch( - new Request(iri, { headers: accept }), + new Request(iri, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); return await response.json(); }; - const first = await fetchJson(`${actorIri}/outbox?cursor=`); + const first = await fetchJson(`${ACTOR_IRI}/outbox?cursor=`); assert.equal(first.orderedItems.length, 20); - // Equal publication times use descending IDs as a stable tie-breaker. - const expected = Array.from({ length: 12 }, (_, index) => 22 - 2 * index) - .flatMap((index) => [objects[index], objects[index + 1]]) - .map((id) => values(id!).iri); + const order = objects + .map((_, index) => index) + .toSorted((left, right) => rank(right) - rank(left)); + const expected = order.map((index) => values(objects[index]!).iri); assert.deepEqual( first.orderedItems.map((item: { object: string }) => item.object), expected.slice(0, 20), ); - // A cursor remains valid even if its activity has since been deleted. - const boundary = new URL(first.next).searchParams - .get("cursor")! - .split("|")[1]!; + // The cursor is the position of the page's last item, and remains + // valid even if that activity has since been deleted. + assert.equal( + new URL(first.next).searchParams.get("cursor"), + String(-1 - rank(order[19]!)), + ); await db .delete(schema.resources) - .where(eq(schema.resources.id, boundary as Uuid)); + .where(eq(schema.resources.id, activityIds[order[19]!]!)); const last = await fetchJson(first.next); assert.deepEqual( last.orderedItems.map((item: { object: string }) => item.object), @@ -672,12 +687,12 @@ describe("ActivityPub outbox", () => { assert.ok(activity); assert.equal(activity.objectId, null); const response = await federation.fetch( - new Request(createIri(object.activityId), { headers: accept }), + new Request(createIri(object.activityId), { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 404); const outbox = await federation.fetch( - new Request(`${actorIri}/outbox?cursor=`, { headers: accept }), + new Request(`${ACTOR_IRI}/outbox?cursor=`, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(outbox.status, 200); @@ -688,7 +703,7 @@ describe("ActivityPub outbox", () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db); const response = await federation.fetch( - new Request(`${actorIri}/outbox?cursor=`, { headers: accept }), + new Request(`${ACTOR_IRI}/outbox?cursor=`, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); @@ -714,7 +729,7 @@ describe("stored collection membership and independent activity addressing", () assert.notEqual(activity.id, object.id); assert.equal(activity.resource.iri, createIri(activity.id)); const fetch = (iri: string) => - federation.fetch(new Request(iri, { headers: accept }), { + federation.fetch(new Request(iri, { headers: ACCEPT }), { contextData: undefined, }); assert.equal((await fetch(createIri(object.activityId))).status, 200); @@ -724,11 +739,11 @@ describe("stored collection membership and independent activity addressing", () assert.equal((await fetch(createIri(object.activityId))).status, 404); assert.equal((await fetch(object.iri)).status, 200); assert.equal( - (await (await fetch(`${actorIri}/outbox`)).json()).totalItems, + (await (await fetch(`${ACTOR_IRI}/outbox`)).json()).totalItems, 0, ); assert.deepEqual( - (await (await fetch(`${actorIri}/outbox?cursor=`)).json()) + (await (await fetch(`${ACTOR_IRI}/outbox?cursor=`)).json()) .orderedItems ?? [], [], ); @@ -743,7 +758,7 @@ describe("stored collection membership and independent activity addressing", () await seedLocalActor(db); await seedRemoteActor(db); const fetch = (iri: string) => - federation.fetch(new Request(iri, { headers: accept }), { + federation.fetch(new Request(iri, { headers: ACCEPT }), { contextData: undefined, }); for (const role of ["followers", "following", "featured"] as const) { @@ -758,7 +773,7 @@ describe("stored collection membership and independent activity addressing", () itemId: remoteActorId, position: 0, }); - const response = await fetch(`${actorIri}/${role}`); + const response = await fetch(`${ACTOR_IRI}/${role}`); assert.equal(response.status, 200); const body = await response.json(); const item = body.orderedItems?.[0] ?? body.items?.[0]; @@ -777,15 +792,15 @@ describe("durable actor signing keys", () => { await seedLocalActor(db, { keys: false }); assert.equal(await db.$count(schema.localActorKeys), 0); const response = await federation.fetch( - new Request(actorIri, { headers: accept }), + new Request(ACTOR_IRI, { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); const body = await response.json(); - assert.equal(body.publicKey.id, `${actorIri}#main-key`); + assert.equal(body.publicKey.id, `${ACTOR_IRI}#main-key`); assert.equal(body.assertionMethod.length, 2); assert.ok(body.publicKey.publicKeyPem); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); const first = await ctx.getActorKeyPairs(localActorId); assert.deepEqual( first.map((pair) => pair.privateKey.algorithm.name), @@ -799,7 +814,7 @@ describe("durable actor signing keys", () => { ); const fresh = await createFederation(db, { kv: new MemoryKvStore() }); const again = await fresh - .createContext(new URL(actorIri), undefined) + .createContext(new URL(ACTOR_IRI), undefined) .getActorKeyPairs(localActorId); assert.deepEqual( await crypto.subtle.exportKey("jwk", first[0]!.publicKey), @@ -810,30 +825,30 @@ describe("durable actor signing keys", () => { }); it("publishes canonical key ownership for uppercase UUID requests", async () => { await withFederation(async ({ db, federation }) => { + const ID = "abcdefab-cdef-4abc-8def-abcdefabcdef" as Uuid; + const IRI = `https://test-instance.drfed.org/users/${ID}`; await seedLocalActor(db); - const id = "abcdefab-cdef-4abc-8def-abcdefabcdef" as Uuid; - await db.insert(schema.localActors).values({ id }); - const iri = `https://test-instance.drfed.org/users/${id}`; + await db.insert(schema.localActors).values({ id: ID }); await seedActors(db, { - id, - localId: id, - iri, + id: ID, + localId: ID, + iri: IRI, instanceId: "00000000-0000-4000-8000-000000000101" as Uuid, type: "Person", username: "uppercase-test", - inboxUrl: `${iri}/inbox`, + inboxUrl: `${IRI}/inbox`, created: Temporal.Now.instant(), }); const response = await federation.fetch( - new Request(iri.replace(id, id.toUpperCase()), { headers: accept }), + new Request(IRI.replace(ID, ID.toUpperCase()), { headers: ACCEPT }), { contextData: undefined }, ); assert.equal(response.status, 200); const body = await response.json(); - assert.equal(body.id, iri); - assert.equal(body.publicKey.owner, iri); - assert.equal(body.publicKey.id, `${iri}#main-key`); - for (const key of body.assertionMethod) assert.equal(key.controller, iri); + assert.equal(body.id, IRI); + assert.equal(body.publicKey.owner, IRI); + assert.equal(body.publicKey.id, `${IRI}#main-key`); + for (const key of body.assertionMethod) assert.equal(key.controller, IRI); }); }); for (const requestOrigin of [ @@ -846,22 +861,22 @@ describe("durable actor signing keys", () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db); const response = await federation.fetch( - new Request(new URL(new URL(actorIri).pathname, requestOrigin), { - headers: accept, + new Request(new URL(new URL(ACTOR_IRI).pathname, requestOrigin), { + headers: ACCEPT, }), { contextData: undefined }, ); assert.equal(response.status, 200); const body = await response.json(); - assert.equal(body.id, actorIri); - assert.equal(body.publicKey.owner, actorIri); - assert.equal(body.publicKey.id, `${actorIri}#main-key`); + assert.equal(body.id, ACTOR_IRI); + assert.equal(body.publicKey.owner, ACTOR_IRI); + assert.equal(body.publicKey.id, `${ACTOR_IRI}#main-key`); assert.deepEqual( body.assertionMethod.map((key: { id: string }) => key.id), - [`${actorIri}#multikey-1`, `${actorIri}#multikey-2`], + [`${ACTOR_IRI}#multikey-1`, `${ACTOR_IRI}#multikey-2`], ); for (const key of body.assertionMethod) { - assert.equal(key.controller, actorIri); + assert.equal(key.controller, ACTOR_IRI); } }); }); @@ -876,7 +891,7 @@ describe("durable actor signing keys", () => { await db .delete(schema.localActorKeys) .where(eq(schema.localActorKeys.type, "Ed25519")); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); assert.equal( (await ensureActorKeyPairs(db, ctx, localActorId)).length, 2, @@ -909,7 +924,7 @@ describe("durable actor signing keys", () => { it(`does not persist keys when ${deletion} deletion happens during generation`, async () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db, { keys: false }); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); let deleted = false; const pairs = await ensureActorKeyPairs( db, @@ -945,7 +960,7 @@ describe("durable actor signing keys", () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db, { keys: false }); await seedRemoteActor(db); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); assert.deepEqual(await ensureActorKeyPairs(db, ctx, "invalid"), []); assert.deepEqual(await ensureActorKeyPairs(db, ctx, uuid()), []); assert.deepEqual(await ensureActorKeyPairs(db, ctx, remoteActorId), []); @@ -970,7 +985,7 @@ describe("durable actor signing keys", () => { .set({ localId }) .where(eq(schema.actors.id, localActorId)); await seedActorKeys(db, localId); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); assert.equal( (await ensureActorKeyPairs(db, ctx, localActorId)).length, 2, @@ -996,7 +1011,7 @@ describe("durable actor signing keys", () => { it("coalesces generation and returns a competing persisted winner", async () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db, { keys: false }); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); let count = 0; @@ -1032,7 +1047,7 @@ describe("durable actor signing keys", () => { it("sanitizes failures and clears failed in-flight generation", async () => { await withFederation(async ({ db, federation }) => { await seedLocalActor(db, { keys: false }); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); await assert.rejects( ensureActorKeyPairs(db, ctx, localActorId, () => Promise.reject(new Error("PRIVATE_SECRET")), @@ -1068,7 +1083,7 @@ describe("durable actor signing keys", () => { manuallyStartQueue: true, taskQueueResolution: "strict", }); - const ctx = federation.createContext(new URL(actorIri), undefined); + const ctx = federation.createContext(new URL(ACTOR_IRI), undefined); await enqueueActorKeyGeneration(ctx, [localActorId]); assert.equal((await queue.getDepth()).queued, 1); const abort = new AbortController(); @@ -1092,13 +1107,13 @@ describe("durable actor signing keys", () => { manuallyStartQueue: true, }); await enqueueActorKeyGeneration( - other.createContext(new URL(actorIri), undefined), + other.createContext(new URL(ACTOR_IRI), undefined), [localActorId], ); assert.equal((await otherQueue.getDepth()).queued, 1); const noQueue = await createFederation(db, { kv: new MemoryKvStore() }); await enqueueActorKeyGeneration( - noQueue.createContext(new URL(actorIri), undefined), + noQueue.createContext(new URL(ACTOR_IRI), undefined), [localActorId], ); }); diff --git a/packages/federation/src/local-write-postgres.test.ts b/packages/federation/src/local-write-postgres.test.ts index 7067886..7843d2c 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -25,12 +25,20 @@ import createFederation from "@drfed/federation"; import { type CommitOutcome, type CreateRequest, + type JsonObject, type LocalWriteEnvironment, + type LocalWriteRequest, + type SavedResource, commitLocalWrite, previewLocalWrite, } from "@drfed/federation/local-write"; +import { + loadActivitySnapshot, + publicDocument, +} from "@drfed/federation/serving"; import { type Database, migrate, relations, schema } from "@drfed/models"; import { PUBLIC_IRI } from "@drfed/models/resource"; +import { loadCurrentResourceRevision } from "@drfed/models/resource-revision"; import type { Uuid } from "@drfed/models/uuid"; import { MemoryKvStore } from "@fedify/fedify"; import { describe, it } from "@logtape/testing-node/autoload"; @@ -44,13 +52,18 @@ import { seedActors, seedLocalActor, } from "./seed.test.ts"; +import { + ACTIVITY_TYPES, + BLIND_RECIPIENT, + typeDocument, +} from "./serving-fixture.test.ts"; // This service is an isolated, author-owned test cluster. // oxlint-disable-next-line node/no-process-env const postgresUrl = process.env.DRFED_TEST_POSTGRES_URL; -const accountId = "00000000-0000-4000-8000-000000000301" as Uuid; -const secondActorId = "00000000-0000-4000-8000-000000000203" as Uuid; -const principal = { accountId }; +const ACCOUNT_ID = "00000000-0000-4000-8000-000000000301" as Uuid; +const SECOND_ACTOR_ID = "00000000-0000-4000-8000-000000000203" as Uuid; +const PRINCIPAL = { accountId: ACCOUNT_ID }; interface Connection { readonly db: Database; @@ -82,13 +95,13 @@ async function withDatabase( main = connect(url.href); await seedLocalActor(main.db, { keys: false }); await main.db.insert(schema.accounts).values({ - id: accountId, + id: ACCOUNT_ID, email: "member@example.com", name: "Member", created, }); await main.db.insert(schema.instanceMembers).values({ - accountId, + accountId: ACCOUNT_ID, instanceId: localInstanceId, accepted: created, created, @@ -117,17 +130,17 @@ async function withDatabase( /** Seeds a second local actor of the member's instance. */ async function seedSecondActor(db: Database): Promise { - await db.insert(schema.localActors).values({ id: secondActorId }); + await db.insert(schema.localActors).values({ id: SECOND_ACTOR_ID }); await seedActors( db, { - id: secondActorId, - localId: secondActorId, + id: SECOND_ACTOR_ID, + localId: SECOND_ACTOR_ID, instanceId: localInstanceId, type: "Person", username: "bob", - iri: `https://test-instance.drfed.org/users/${secondActorId}`, - inboxUrl: `https://test-instance.drfed.org/users/${secondActorId}/inbox`, + iri: `https://test-instance.drfed.org/users/${SECOND_ACTOR_ID}`, + inboxUrl: `https://test-instance.drfed.org/users/${SECOND_ACTOR_ID}/inbox`, profileUrl: "https://test-instance.drfed.org/@bob", created, }, @@ -156,7 +169,7 @@ async function previewToken( env: LocalWriteEnvironment, request: CreateRequest, ): Promise { - const preview = await previewLocalWrite(env, principal, request); + const preview = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(preview.ok); return preview.preview.token; } @@ -177,7 +190,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { connections.map((connection) => commitLocalWrite( { ...env, db: connection.db }, - principal, + PRINCIPAL, request, { previewToken: token, @@ -215,7 +228,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { ); const requests = [ note(recipients), - note(recipients.toReversed(), secondActorId), + note(recipients.toReversed(), SECOND_ACTOR_ID), ]; const tokens = await Promise.all( requests.map((request) => previewToken(env, request)), @@ -235,7 +248,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { connections.map((connection, index) => commitLocalWrite( { ...env, db: connection.db }, - principal, + PRINCIPAL, requests[index]!, { previewToken: tokens[index]! }, ), @@ -276,7 +289,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { await tx`SELECT id FROM actors WHERE id = ${localActorId} FOR UPDATE`; pending = commitLocalWrite( { ...env, db: writer.db }, - principal, + PRINCIPAL, request, { previewToken: token }, ); @@ -289,7 +302,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { setTimeout(resolve, 50); }); } - await tx`DELETE FROM instance_members WHERE account_id = ${accountId}`; + await tx`DELETE FROM instance_members WHERE account_id = ${ACCOUNT_ID}`; }); // The writer goes on only once the revocation has committed. const outcome = await pending; @@ -304,3 +317,159 @@ describe("commitLocalWrite() on PostgreSQL", () => { }, ); }); + +/** + * Previews and commits a write with a no-op dispatch reservation. + * @returns The saved resources. + */ +async function write( + env: LocalWriteEnvironment, + request: LocalWriteRequest, +): Promise { + const preview = await previewLocalWrite(env, PRINCIPAL, request); + assert.ok(preview.ok, JSON.stringify(!preview.ok && preview.error)); + const outcome = await commitLocalWrite(env, PRINCIPAL, request, { + previewToken: preview.preview.token, + reserveDispatch: () => Promise.resolve(), + }); + assert.ok(outcome.ok, JSON.stringify(!outcome.ok && outcome.error)); + return outcome.result.resources; +} + +/** + * Requests a resource from the federation as ActivityStreams. + * @returns The status, and the body of a success or a `Tombstone`'s 410. + */ +async function fetchDocument( + env: LocalWriteEnvironment, + iri: string, +): Promise<{ status: number; body: Record }> { + const response = await env.federation.fetch( + new Request(iri, { headers: { accept: "application/activity+json" } }), + { contextData: undefined }, + ); + return { + status: response.status, + body: + response.ok || response.status === 410 + ? ((await response.json()) as Record) + : {}, + }; +} + +describe("federation reads on PostgreSQL", () => { + const ACTOR_IRI = `https://test-instance.drfed.org/users/${localActorId}`; + + it( + "serves saved documents as stored in JSON columns, and hides deleted content", + options, + async () => { + await withDatabase(async (_url, env) => { + const AS = "https://www.w3.org/ns/activitystreams"; + const get = (iri: string) => fetchDocument(env, iri); + const [like] = await write(env, { + operation: "create", + actorId: localActorId, + document: { + "@context": [ + AS, + { ex: "https://ext.example/ns#", mood: "ex:mood" }, + ], + type: "Like", + to: [PUBLIC_IRI], + bcc: ["https://remote.example/users/blind"], + object: "https://remote.example/notes/1", + mood: { zeta: 1, alpha: [true, null], text: '안녕 "🙂" \\ é' }, + }, + resources: [{ path: [], kind: "activity" }], + publish: true, + }); + const served = await get(like!.iri); + assert.equal(served.status, 200); + assert.deepEqual(served.body, publicDocument(like!.document)); + // Out of the order jsonb would store them in. + assert.deepEqual(Object.keys(served.body.mood as object), [ + "zeta", + "alpha", + "text", + ]); + const [noteResource, create] = await write(env, { + operation: "create", + actorId: localActorId, + document: { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "x", to: [PUBLIC_IRI] }, + }, + resources: [ + { path: [], kind: "activity" }, + { path: ["object"], kind: "object" }, + ], + publish: true, + }); + const head = await loadCurrentResourceRevision( + env.db, + noteResource!.id, + ); + const [deletion] = await write(env, { + operation: "delete", + actorId: localActorId, + objectId: noteResource!.id, + expectedRevisionId: head!.id, + }); + assert.equal((await get(noteResource!.iri)).status, 410); + assert.equal((await get(create!.iri)).status, 404); + assert.equal((await get(deletion!.iri)).status, 200); + const collection = await get(`${ACTOR_IRI}/outbox`); + const page = await get(`${ACTOR_IRI}/outbox?cursor=`); + const items = page.body.orderedItems as JsonObject[]; + assert.deepEqual( + items.map(({ id }) => id), + [deletion!.iri, like!.iri], + ); + assert.equal(collection.body.totalItems, 2); + }); + }, + ); + + it( + "serves every activity type as saved, and pages them in one outbox", + options, + async () => { + await withDatabase(async (_url, env) => { + const get = (iri: string) => fetchDocument(env, iri); + const iris: string[] = []; + for (const type of ACTIVITY_TYPES) { + const [saved] = await write(env, { + operation: "create", + actorId: localActorId, + document: typeDocument(type), + resources: [{ path: [], kind: "activity" }], + publish: true, + }); + // The JSON column keeps the blind recipient that serving removes. + const stored = await loadActivitySnapshot(env.db, saved!.id); + assert.ok(stored != null, type); + assert.deepEqual(stored.document, saved!.document, type); + assert.deepEqual(stored.document.bto, [BLIND_RECIPIENT], type); + const { status, body } = await get(saved!.iri); + assert.equal(status, 200, type); + assert.deepEqual(body, publicDocument(stored.document), type); + iris.push(saved!.iri); + } + const ids = (body: Record) => + (body.orderedItems as JsonObject[]).map(({ id }) => id); + const collection = await get(`${ACTOR_IRI}/outbox`); + assert.equal(collection.body.totalItems, ACTIVITY_TYPES.length); + const first = await get(`${ACTOR_IRI}/outbox?cursor=`); + const last = await get(String(first.body.next)); + assert.equal(last.body.next, undefined); + assert.deepEqual( + [...ids(first.body), ...ids(last.body)], + iris.toReversed(), + ); + }); + }, + ); +}); diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index 6708aff..4e53e3f 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -52,21 +52,28 @@ import { } from "./seed.test.ts"; const AS = "https://www.w3.org/ns/activitystreams"; -const rootOrigin = new URL("https://drfed.org"); -const accountId = "00000000-0000-4000-8000-000000000301" as Uuid; -const principal = { accountId }; -const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; +const ROOT_ORIGIN = new URL("https://drfed.org"); +const ACCOUNT_ID = "00000000-0000-4000-8000-000000000301" as Uuid; +const PRINCIPAL = { accountId: ACCOUNT_ID }; +const ACTOR_IRI = `https://test-instance.drfed.org/users/${localActorId}`; +const NOTE = { + "@context": AS, + type: "Note", + content: "

Hello

", + to: [PUBLIC_IRI], + bcc: ["https://remote.example/users/blind"], +}; async function seedMember(db: Database, accepted: boolean = true) { await seedLocalActor(db, { keys: false }); await db.insert(schema.accounts).values({ - id: accountId, + id: ACCOUNT_ID, email: "member@example.com", name: "Member", created, }); await db.insert(schema.instanceMembers).values({ - accountId, + accountId: ACCOUNT_ID, instanceId: localInstanceId, accepted: accepted ? created : null, created, @@ -79,7 +86,7 @@ async function environment( return { db: harness.db, federation: harness.federation, - rootOrigin, + rootOrigin: ROOT_ORIGIN, previewKey: await crypto.subtle.generateKey( { name: "HMAC", hash: "SHA-256" }, false, @@ -126,9 +133,9 @@ async function save( request: CreateRequest, reserveDispatch?: (activity: SavedResource) => Promise, ) { - const preview = await previewLocalWrite(env, principal, request); + const preview = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(preview.ok, JSON.stringify(!preview.ok && preview.error)); - const outcome = await commitLocalWrite(env, principal, request, { + const outcome = await commitLocalWrite(env, PRINCIPAL, request, { previewToken: preview.preview.token, ...(reserveDispatch == null ? {} @@ -138,31 +145,23 @@ async function save( return { preview: preview.preview, result: outcome.result }; } -const note = { - "@context": AS, - type: "Note", - content: "

Hello

", - to: [PUBLIC_IRI], - bcc: ["https://remote.example/users/blind"], -}; - describe("previewLocalWrite() and commitLocalWrite()", () => { it("saves an object alone, without an activity, outbox entry, or delivery", async () => { await withFederation(async (harness) => { await seedMember(harness.db); const env = await environment(harness); const before = await counts(harness.db); - const preview = await previewLocalWrite(env, principal, create(note)); + const preview = await previewLocalWrite(env, PRINCIPAL, create(NOTE)); assert.ok(preview.ok); assert.deepEqual(await counts(harness.db), before); const [prepared] = preview.preview.resources; assert.ok(prepared != null); assert.match( prepared.iri, - new RegExp(`^${actorIri}/[0-9a-f-]{36}$`, "u"), + new RegExp(`^${ACTOR_IRI}/[0-9a-f-]{36}$`, "u"), ); assert.equal(prepared.document.id, prepared.iri); - assert.equal(prepared.document.attributedTo, actorIri); + assert.equal(prepared.document.attributedTo, ACTOR_IRI); assert.equal(typeof prepared.document.published, "string"); assert.deepEqual( preview.preview.changes @@ -174,7 +173,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ["published", "assign_server_owned_field"], ], ); - const outcome = await commitLocalWrite(env, principal, create(note), { + const outcome = await commitLocalWrite(env, PRINCIPAL, create(NOTE), { previewToken: preview.preview.token, }); assert.ok(outcome.ok); @@ -195,7 +194,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { where: { id: saved.id }, }); assert.equal(resource?.kind, "object"); - // Revision-only until generalized reads arrive; nothing else changed. + // Revision-only, without legacy typed rows; nothing else changed. assert.equal(await harness.db.$count(schema.objects), 0); assert.equal(await harness.db.$count(schema.activities), 0); assert.equal(await harness.db.$count(schema.collectionItems), 0); @@ -221,7 +220,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { const { result } = await save(env, create(document)); assert.match( result.resources[0]!.iri, - new RegExp(`^${actorIri}/`, "u"), + new RegExp(`^${ACTOR_IRI}/`, "u"), ); } }); @@ -231,7 +230,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { await withFederation(async (harness) => { await seedMember(harness.db); const env = await environment(harness); - const { result: first } = await save(env, create(note)); + const { result: first } = await save(env, create(NOTE)); const noteResource = first.resources[0]!; const like = { "@context": AS, @@ -249,7 +248,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { saved.iri, /^https:\/\/test-instance\.drfed\.org\/ap\/activities\//u, ); - assert.equal(saved.document.actor, actorIri); + assert.equal(saved.document.actor, ACTOR_IRI); assert.equal(saved.document.object, noteResource.iri); assert.deepEqual( saved.relations.map(({ property, representation, targetIri }) => [ @@ -258,7 +257,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { targetIri, ]), [ - ["actor", "reference", actorIri], + ["actor", "reference", ACTOR_IRI], ["object", "reference", noteResource.iri], ["instrument", "reference", "https://remote.example/objects/1"], ], @@ -298,7 +297,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { assert.ok(nested != null && root != null); assert.match(root.iri, /\/ap\/creates\/[0-9a-f-]{36}$/u); assert.equal(nested.document.id, nested.iri); - assert.equal(nested.document.attributedTo, actorIri); + assert.equal(nested.document.attributedTo, ACTOR_IRI); assert.equal(nested.document.published, root.document.published); if (representation === "embedded") { const { "@context": _, ...embedded } = nested.document; @@ -343,7 +342,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ), ); const [like, undo] = result.resources; - assert.equal(like?.document.actor, actorIri); + assert.equal(like?.document.actor, ACTOR_IRI); assert.match(like!.iri, /\/ap\/activities\//u); assert.equal( (undo?.document.object as { id?: string } | undefined)?.id, @@ -425,7 +424,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { const env = await environment(harness); const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, create({ "@context": [AS, { recipients: { "@id": "as:to", "@type": "@id" } }], type: "Note", @@ -510,9 +509,9 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { it("refuses local identity claims the actor may not make, however they are spelled", async () => { await withFederation(async (harness) => { + const OTHER = "https://other.drfed.org/users/victim"; await seedMember(harness.db); const env = await environment(harness); - const other = "https://other.drfed.org/users/victim"; for (const [document, path] of [ [ { @@ -520,7 +519,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { type: "Undo", object: { type: "Like", - actor: other, + actor: OTHER, object: "https://x.example/", }, }, @@ -563,7 +562,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { const before = await counts(harness.db); const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, create(document as never, [{ path: [], kind: "activity" }]), ); assert.ok(!outcome.ok); @@ -583,7 +582,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { { "@context": AS, type: "Like", - attributedTo: other, + attributedTo: OTHER, object: "https://x.example/", }, [{ path: [], kind: "activity" }], @@ -595,7 +594,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { type: "Undo", object: { type: "Like", - attributedTo: other, + attributedTo: OTHER, object: "https://x.example/", }, }, @@ -606,14 +605,14 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ["object", "attributedTo"], ], [ - { "@context": AS, type: "Question", name: "q", actor: other }, + { "@context": AS, type: "Question", name: "q", actor: OTHER }, [{ path: [], kind: "object" }], ["actor"], ], ] as const) { const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, create(document as never, resources as never), ); assert.ok(!outcome.ok); @@ -625,7 +624,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ); } // A claim of the acting actor itself, or a remote actor, is allowed. - for (const actor of [actorIri, "https://remote.example/users/bob"]) { + for (const actor of [ACTOR_IRI, "https://remote.example/users/bob"]) { await save( env, create( @@ -641,8 +640,8 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { // A new resource's own server-owned fields must match the server. const conflict = await previewLocalWrite( env, - principal, - create({ ...note, attributedTo: "https://remote.example/users/bob" }), + PRINCIPAL, + create({ ...NOTE, attributedTo: "https://remote.example/users/bob" }), ); assert.ok(!conflict.ok); assert.ok( @@ -653,21 +652,177 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { }); }); + it("checks identity claims in the node objects of extension values", async () => { + await withFederation(async (harness) => { + const LINK = "https://remote.example/links/1"; + const VICTIM = "https://other.drfed.org/users/victim"; + const FORGED = "https://test-instance.drfed.org/users/x/forged"; + await seedMember(harness.db); + const env = await environment(harness); + const quoting = (quoted: Record) => + create( + { + "@context": [AS, { ex: "https://ext.example/ns#" }], + type: "Like", + object: "https://x.example/", + "ex:quoted": quoted, + } as never, + [{ path: [], kind: "activity" }], + ); + for (const [quoted, path] of [ + [ + { + type: "Note", + id: "https://test-instance.drfed.org/users/x/forged", + content: "forged", + }, + ["ex:quoted", "id"], + ], + [ + { + type: "Note", + attributedTo: "https://other.drfed.org/users/victim", + content: "forged", + }, + ["ex:quoted", "attributedTo"], + ], + [ + { + type: "Note", + attributedTo: { + type: "Link", + href: "https://other.drfed.org/users/victim", + }, + }, + ["ex:quoted", "attributedTo", "href"], + ], + ] as const) { + const outcome = await previewLocalWrite( + env, + PRINCIPAL, + quoting(quoted), + ); + assert.ok(!outcome.ok); + assert.deepEqual( + outcome.error.type === "invalid_document" + ? outcome.error.diagnostics + .filter(({ category }) => category === "authority") + .map(({ reason, path: found }) => [reason, found]) + : outcome.error.type, + [["unauthorized_local_identity", path]], + ); + } + // A reference names what a Link embedded elsewhere with its id does. + for (const [request, path] of [ + [ + create( + { + "@context": AS, + type: "Like", + object: { type: "Note", content: "forged", attributedTo: LINK }, + target: { type: "Link", id: LINK, href: VICTIM }, + }, + [{ path: [], kind: "activity" }], + ), + ["target", "href"], + ], + [ + create( + { + "@context": [AS, { ex: "https://ext.example/ns#" }], + type: "Like", + object: "https://x.example/", + "ex:quoted": [ + { type: "Note", attributedTo: LINK }, + { id: LINK, type: "Link", href: VICTIM }, + ], + } as never, + [{ path: [], kind: "activity" }], + ), + ["ex:quoted", 1, "href"], + ], + ...[{ id: "_:author" }, "_:author"].map( + (author) => + [ + create( + { + "@context": [AS, { ex: "https://ext.example/ns#" }], + type: "Like", + object: "https://x.example/", + "ex:quoted": [ + { type: "Note", attributedTo: author }, + { id: "_:author", type: "Link", href: VICTIM }, + ], + } as never, + [{ path: [], kind: "activity" }], + ), + ["ex:quoted", 1, "href"], + ] as const, + ), + ] as const) { + const outcome = await previewLocalWrite(env, PRINCIPAL, request); + assert.ok(!outcome.ok, JSON.stringify(request.document)); + assert.deepEqual( + outcome.error.type === "invalid_document" + ? outcome.error.diagnostics + .filter(({ category }) => category === "authority") + .map(({ reason, path: found }) => [reason, found]) + : outcome.error.type, + [["unauthorized_local_identity", path]], + ); + } + // A Link whose href is its own id claims it through both; each path + // is denied once. + const self = await previewLocalWrite( + env, + PRINCIPAL, + quoting({ + type: "Note", + attributedTo: { type: "Link", id: FORGED, href: FORGED }, + }), + ); + assert.ok(!self.ok); + assert.deepEqual( + self.error.type === "invalid_document" + ? self.error.diagnostics + .filter(({ category }) => category === "authority") + .map(({ reason, path }) => [reason, path]) + : self.error.type, + [ + [ + "unauthorized_local_identity", + ["ex:quoted", "attributedTo", "href"], + ], + ["unauthorized_local_identity", ["ex:quoted", "attributedTo", "id"]], + ], + ); + // A remote identity is not checked, as in any embedded document. + await save( + env, + quoting({ + type: "Note", + id: "https://remote.example/notes/1", + attributedTo: "https://remote.example/users/bob", + }), + ); + }); + }); + it("does not accept an id that is registered only as a reference", async () => { await withFederation(async (harness) => { + const FORGED = "https://test-instance.drfed.org/ap/activities/forged"; await seedMember(harness.db); const env = await environment(harness); - const forged = "https://test-instance.drfed.org/ap/activities/forged"; const request = create( { "@context": AS, type: "Undo", - object: { type: "Like", id: forged, object: "https://x.example/" }, + object: { type: "Like", id: FORGED, object: "https://x.example/" }, }, [{ path: [], kind: "activity" }], ); const refused = async () => { - const outcome = await previewLocalWrite(env, principal, request); + const outcome = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(!outcome.ok); assert.deepEqual( outcome.error.type === "invalid_document" @@ -680,9 +835,9 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { }; await refused(); // Addressing the IRI registers it, but only as a reference. - await save(env, create({ ...note, to: forged })); + await save(env, create({ ...NOTE, to: FORGED })); const registered = await harness.db.query.resources.findFirst({ - where: { iri: forged }, + where: { iri: FORGED }, }); assert.equal(registered?.kind, "unknown"); await refused(); @@ -694,7 +849,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { await withFederation(async (harness) => { await seedMember(harness.db, scenario !== "pending"); const env = await environment(harness); - const preview = await previewLocalWrite(env, principal, create(note)); + const preview = await previewLocalWrite(env, PRINCIPAL, create(NOTE)); assert.ok(scenario === "pending" ? !preview.ok : preview.ok); if (scenario === "missing") { await harness.db.delete(schema.instanceMembers); @@ -709,7 +864,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { .where(eq(schema.actors.id, localActorId)); } const before = await counts(harness.db); - const outcome = await commitLocalWrite(env, principal, create(note), { + const outcome = await commitLocalWrite(env, PRINCIPAL, create(NOTE), { previewToken: preview.ok ? preview.preview.token : "invalid", }); assert.ok(!outcome.ok); @@ -726,8 +881,8 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { await withFederation(async (harness) => { await seedMember(harness.db); const env = await environment(harness); - const request = create(note); - const preview = await previewLocalWrite(env, principal, request); + const request = create(NOTE); + const preview = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(preview.ok); const { token } = preview.preview; const [body, signature] = token.split("."); @@ -741,9 +896,9 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { request, "stale_preview", ], - [token, create({ ...note, content: "changed" }), "stale_preview"], + [token, create({ ...NOTE, content: "changed" }), "stale_preview"], ] as const) { - const outcome = await commitLocalWrite(env, principal, changed, { + const outcome = await commitLocalWrite(env, PRINCIPAL, changed, { previewToken, }); assert.ok(!outcome.ok); @@ -757,7 +912,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ); assert.ok(!other.ok); assert.equal(other.error.type, "stale_preview"); - const first = await commitLocalWrite(env, principal, request, { + const first = await commitLocalWrite(env, PRINCIPAL, request, { previewToken: token, }); assert.ok(first.ok); @@ -766,7 +921,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { preview.preview.resources[0]?.document, ); const before = await counts(harness.db); - const replay = await commitLocalWrite(env, principal, request, { + const replay = await commitLocalWrite(env, PRINCIPAL, request, { previewToken: token, }); assert.ok(!replay.ok); @@ -792,17 +947,17 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ], { publish: true }, ); - const preview = await previewLocalWrite(env, principal, request); + const preview = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(preview.ok); const { token: previewToken } = preview.preview; - const missing = await commitLocalWrite(env, principal, request, { + const missing = await commitLocalWrite(env, PRINCIPAL, request, { previewToken, }); assert.ok(!missing.ok); assert.equal(missing.error.type, "invalid_request"); const before = await counts(harness.db); await assert.rejects( - commitLocalWrite(env, principal, request, { + commitLocalWrite(env, PRINCIPAL, request, { previewToken, reserveDispatch: () => Promise.reject(new Error("queue down")), }), @@ -810,7 +965,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ); assert.deepEqual(await counts(harness.db), before); const reserved: SavedResource[] = []; - const retried = await commitLocalWrite(env, principal, request, { + const retried = await commitLocalWrite(env, PRINCIPAL, request, { previewToken, reserveDispatch: (_tx, activity) => { reserved.push(activity); @@ -825,7 +980,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { items.map(({ itemId }) => itemId), [root.id], ); - // Saved without a legacy typed row, it is not served yet. + // Saved without a legacy typed row; it is served from its revision. assert.equal(await harness.db.$count(schema.activities), 0); }); }); @@ -874,7 +1029,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ] as const) { const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, create(document, resources as never, extra as never), ); assert.ok(!outcome.ok, JSON.stringify(resources)); @@ -882,8 +1037,8 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { } const publishedObject = await previewLocalWrite( env, - principal, - create(note, [{ path: [], kind: "object" }], { publish: true }), + PRINCIPAL, + create(NOTE, [{ path: [], kind: "object" }], { publish: true }), ); assert.ok(!publishedObject.ok); assert.equal(publishedObject.error.type, "invalid_request"); @@ -911,7 +1066,7 @@ async function seedLegacyNote( id, activityId, actorId: localActorId, - iri: `${actorIri}/${id}`, + iri: `${ACTOR_IRI}/${id}`, type: "Note", contentHtml: "

First

", language: "en", @@ -940,7 +1095,7 @@ async function seedLegacyNote( }); const head = await loadCurrentResourceRevision(harness.db, id); assert.ok(head != null); - return { id, activityId, iri: `${actorIri}/${id}`, head }; + return { id, activityId, iri: `${ACTOR_IRI}/${id}`, head }; } async function current(db: Database, id: Uuid) { @@ -956,9 +1111,9 @@ async function change( request: LocalWriteRequest, reserveDispatch: CommitOptions["reserveDispatch"] = reserve, ) { - const preview = await previewLocalWrite(env, principal, request); + const preview = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(preview.ok, JSON.stringify(!preview.ok && preview.error)); - const outcome = await commitLocalWrite(env, principal, request, { + const outcome = await commitLocalWrite(env, PRINCIPAL, request, { previewToken: preview.preview.token, ...(reserveDispatch == null ? {} : { reserveDispatch }), }); @@ -1041,7 +1196,7 @@ describe("edits and deletions of current objects", () => { assert.equal(typeof version?.document.updated, "string"); assert.equal(update?.generated, true); assert.equal(update?.document.type, "Update"); - assert.equal(update?.document.actor, actorIri); + assert.equal(update?.document.actor, ACTOR_IRI); const { "@context": _, ...embedded } = version!.document; assert.deepEqual(update?.document.object, embedded); assert.deepEqual(update?.document.bcc, [ @@ -1070,7 +1225,7 @@ describe("edits and deletions of current objects", () => { await withFederation(async (harness) => { await seedMember(harness.db); const env = await environment(harness); - const { result } = await save(env, create(note)); + const { result } = await save(env, create(NOTE)); const saved = result.resources[0]!; const head = await current(harness.db, saved.id); const { result: edited } = await change( @@ -1088,34 +1243,34 @@ describe("edits and deletions of current objects", () => { await withFederation(async (harness) => { await seedMember(harness.db); const env = await environment(harness); - const { result } = await save(env, create(note)); + const { result } = await save(env, create(NOTE)); const saved = result.resources[0]!; const head = await current(harness.db, saved.id); const document = head.document as JsonObject; const before = await counts(harness.db); const stale = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, uuidV7(), document), ); assert.ok(!stale.ok); assert.equal(stale.error.type, "revision_conflict"); const retyped = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...document, type: "Article" }), ); assert.ok(!retyped.ok); assert.equal(retyped.error.type, "unsupported_edit"); const preview = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...document, content: "B" }), ); assert.ok(preview.ok); const unreserved = await commitLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...document, content: "B" }), { previewToken: preview.preview.token }, ); @@ -1124,7 +1279,7 @@ describe("edits and deletions of current objects", () => { await assert.rejects( commitLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...document, content: "B" }), { previewToken: preview.preview.token, @@ -1138,7 +1293,7 @@ describe("edits and deletions of current objects", () => { await change(env, edit(saved.id, head.id, { ...document, content: "C" })); const late = await commitLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...document, content: "B" }), { previewToken: preview.preview.token, reserveDispatch: reserve }, ); @@ -1148,7 +1303,7 @@ describe("edits and deletions of current objects", () => { // An object attributed to someone else is not the actor's to edit. const foreign = await ensureResource( harness.db, - `${actorIri}/${uuidV7()}`, + `${ACTOR_IRI}/${uuidV7()}`, ); const foreignHead = await appendResourceRevision(harness.db, { resourceId: foreign.id, @@ -1166,7 +1321,7 @@ describe("edits and deletions of current objects", () => { }); const notOwned = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(foreign.id, foreignHead.id, { "@context": AS, type: "Note", @@ -1195,7 +1350,7 @@ describe("edits and deletions of current objects", () => { const head = await current(harness.db, saved.id); const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(saved.id, head.id, { ...(head.document as JsonObject), hidden: ["https://remote.example/users/two"], @@ -1271,7 +1426,7 @@ describe("edits and deletions of current objects", () => { const env = await environment(harness); const resource = await ensureResource( harness.db, - `${actorIri}/${uuidV7()}`, + `${ACTOR_IRI}/${uuidV7()}`, ); const head = await appendResourceRevision(harness.db, { resourceId: resource.id, @@ -1284,12 +1439,12 @@ describe("edits and deletions of current objects", () => { id: resource.iri, type: "Note", content: "x", - attributedTo: actorIri, + attributedTo: ACTOR_IRI, }, }); const added = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(resource.id, head.id, { "@context": AS, type: "Note", @@ -1364,7 +1519,7 @@ describe("edits and deletions of current objects", () => { if (content.trim() === "") { const blank = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(legacy.id, head.id, document), ); assert.ok(!blank.ok); @@ -1378,7 +1533,7 @@ describe("edits and deletions of current objects", () => { .where(eq(schema.objects.id, legacy.id)); const drifted = await previewLocalWrite( env, - principal, + PRINCIPAL, edit(legacy.id, head.id, { ...(head.document as JsonObject), contentMap: null, @@ -1442,7 +1597,7 @@ describe("edits and deletions of current objects", () => { ); const again = await previewLocalWrite( env, - principal, + PRINCIPAL, remove(saved.id, whiteout.id), ); assert.ok(!again.ok); diff --git a/packages/federation/src/local-write/existing.ts b/packages/federation/src/local-write/existing.ts index aff342d..5c59075 100644 --- a/packages/federation/src/local-write/existing.ts +++ b/packages/federation/src/local-write/existing.ts @@ -16,13 +16,13 @@ import { type Database, type Transaction, schema } from "@drfed/models"; import { + ADDRESSING_PROPERTIES, + DIAGNOSTIC_REASONS, type Diagnostic, type JsonValue, type NormalizationChange, type ServerOwnedField, - addressingProperties, describeDocument, - diagnosticReasons, prepareDocument, validateDocument, } from "@drfed/models/document"; @@ -216,7 +216,7 @@ export async function currentAddressing( function copyAddressing(document: JsonObject): Record { const root = describeDocument(document)?.[0]; const copy: Record = {}; - for (const property of addressingProperties) { + for (const property of ADDRESSING_PROPERTIES) { for (const source of root?.properties.get(property) ?? []) { copy[source] = structuredClone(document[source]!); } @@ -438,7 +438,7 @@ export async function prepareEdit( "The object had no publication time, so none can be added.", addedPublished.map((source) => ({ reason: "server_owned_field_conflict", - ...diagnosticReasons.server_owned_field_conflict, + ...DIAGNOSTIC_REASONS.server_owned_field_conflict, path: [source], message: "The publication time is owned by the server.", })), diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index c4d1ba8..86dcd52 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -15,6 +15,8 @@ // along with this program. If not, see . import { + ADDRESSING_PROPERTIES, + DIAGNOSTIC_REASONS, type Diagnostic, type DocumentAuthority, type DocumentNode, @@ -22,9 +24,7 @@ import { type JsonValue, type NormalizationChange, type ServerOwnedField, - addressingProperties, describeDocument, - diagnosticReasons, prepareDocument, validateDocument, } from "@drfed/models/document"; @@ -46,18 +46,18 @@ import { writeError, } from "./types.ts"; -const activityStreams = "https://www.w3.org/ns/activitystreams"; -const questionType = `${activityStreams}#Question`; -const createType = `${activityStreams}#Create`; +const ACTIVITY_STREAMS = "https://www.w3.org/ns/activitystreams"; +const QUESTION_TYPE = `${ACTIVITY_STREAMS}#Question`; +const CREATE_TYPE = `${ACTIVITY_STREAMS}#Create`; /** Activity relations whose embedded documents may become new resources. */ -const nestingProperties: ReadonlySet = new Set([ +const NESTING_PROPERTIES: ReadonlySet = new Set([ "object", "target", "origin", "result", "instrument", ]); -const relationProperties = [ +const RELATION_PROPERTIES = [ "actor", "object", "target", @@ -176,7 +176,7 @@ function parentPath(path: FieldPath): FieldPath { function savableAs(kind: NewResource["kind"], node: DocumentNode): boolean { return kind === "activity" ? node.kind === "activity" - : node.kind === "object" || node.type === questionType; + : node.kind === "object" || node.type === QUESTION_TYPE; } function checkPublication( @@ -227,7 +227,7 @@ function checkDescriptors( } if ( node.property == null || - !nestingProperties.has(node.property) || + !NESTING_PROPERTIES.has(node.property) || !paths.has(pathKey(parentPath(path))) ) { return invalid( @@ -292,19 +292,25 @@ function resourceIri( return ctx.getObjectUri(APObject, { identifier: actor.id, id }).href; } return ( - type === createType + type === CREATE_TYPE ? ctx.getObjectUri(Create, { id }) : ctx.getObjectUri(Activity, { id }) ).href; } +const isInstanceIri = (iri: string, rootOrigin: URL): boolean => + URL.canParse(iri) && classifyHost(new URL(iri), rootOrigin) === "instance"; + /** * Finds identity claims on DrFed instance hosts in documents that are not - * new resources: `actor` and `attributedTo` values, which must be actors of - * the acting actor's instance, and `id` values, which must be registered or - * allocated in this write. Values are compared as the context resolves - * them, so aliases and compact IRIs cannot hide a claim. - * @returns The denied claims' paths. + * new resources, and in the node objects of their extension values, which + * federation reads serve too: `actor` and `attributedTo` values, including + * the `id` or `href` of a document embedded there and the `href` of a `Link` + * embedded elsewhere with the `id` one names, which must be actors of the + * acting actor's instance, and other `id` values, which must be registered + * or allocated in this write. Values are compared as the + * context resolves them, so aliases and compact IRIs cannot hide a claim. + * @returns The denied claims' paths, each once. */ export async function deniedClaims( nodes: readonly DocumentNode[], @@ -315,7 +321,22 @@ export async function deniedClaims( ): Promise { const actors: { iri: string; path: FieldPath }[] = []; const ids: { iri: string; path: FieldPath }[] = []; - for (const node of nodes) { + const described = [ + ...nodes, + ...nodes.flatMap(({ extensions }) => extensions), + ]; + // A reference names what a Link the document embeds with its id does. + const linked = Map.groupBy( + described.flatMap((node) => { + const id = node.identifiers.find(({ property }) => property === "id"); + const href = node.identifiers.find(({ property }) => property === "href"); + return node.kind === "link" && id != null && href != null + ? [{ id: id.iri, iri: href.iri, path: href.path }] + : []; + }), + ({ id }) => id, + ); + for (const node of described) { // A new resource's id and author are server-owned; checking them here // would only repeat the conflict preparation reports. Its other // attribution property, such as attributedTo on an activity, is a claim. @@ -327,28 +348,34 @@ export async function deniedClaims( if (createdKind != null && (property === "id" || property === owned)) { continue; } + // An embedded actor claims its id, or a Link the href it names. const isActor = property === "actor" || property === "attributedTo" || - (property === "id" && embedsActor); + ((property === "id" || property === "href") && embedsActor); if (!isActor && property !== "id") continue; - if ( - !URL.canParse(iri) || - classifyHost(new URL(iri), rootOrigin) !== "instance" - ) { - continue; - } - (isActor ? actors : ids).push({ iri, path }); + const claims = [ + { iri, path }, + ...(isActor ? (linked.get(iri) ?? []) : []), + ]; + (isActor ? actors : ids).push( + ...claims.filter((claim) => isInstanceIri(claim.iri, rootOrigin)), + ); } } const [localActors, registered] = await Promise.all([ lookups.localActors([...new Set(actors.map(({ iri }) => iri))]), lookups.registered([...new Set(ids.map(({ iri }) => iri))]), ]); - return [ + const denied = [ ...actors.filter(({ iri }) => !localActors.has(iri)), ...ids.filter(({ iri }) => !registered.has(iri) && !allocated.has(iri)), - ].map(({ path }) => ({ allowed: false, path })); + ]; + // Several claims can reach one path, as a Link's own href and the href + // its id names; each is denied once. + return [ + ...new Map(denied.map(({ path }) => [pathKey(path), path])).values(), + ].map((path) => ({ allowed: false, path })); } function contextEntries(value: JsonValue | undefined): JsonValue[] { @@ -367,11 +394,11 @@ export function checkIndexable(document: JsonObject): Diagnostic[] { ({ path }) => path.length === 0, ); const diagnostics: Diagnostic[] = []; - for (const property of [...addressingProperties, ...relationProperties]) { + for (const property of [...ADDRESSING_PROPERTIES, ...RELATION_PROPERTIES]) { for (const source of root?.properties.get(property)?.slice(1) ?? []) { diagnostics.push({ reason: "unsupported_value_form", - ...diagnosticReasons.unsupported_value_form, + ...DIAGNOSTIC_REASONS.unsupported_value_form, path: [source], message: `Several spellings of ${property} cannot be saved yet; use one.`, }); @@ -402,9 +429,13 @@ export function indexDocument(document: JsonObject): { const node = children.get(pathKey(path)); if (node == null) return undefined; const property = node.kind === "link" ? "href" : "id"; + // A functional property may be a list with one non-null entry. return node.identifiers.find( (entry) => - entry.property === property && entry.path.length === path.length + 1, + entry.property === property && + (entry.path.length === path.length + 1 || + (entry.path.length === path.length + 2 && + typeof entry.path.at(-1) === "number")), )?.iri; }; const occurrences = ( @@ -423,14 +454,14 @@ export function indexDocument(document: JsonObject): { return value == null ? [] : [{ path: [source], position: 0 }]; }; const addressing: AddressingOccurrence[] = []; - for (const property of addressingProperties) { + for (const property of ADDRESSING_PROPERTIES) { for (const { path, position } of occurrences(property)) { const iri = target(path); if (iri != null) addressing.push({ property, position, iri }); } } const relations: RevisionRelationInput[] = []; - for (const property of relationProperties) { + for (const property of RELATION_PROPERTIES) { for (const { path } of occurrences(property)) { const node = children.get(pathKey(path)); if (node == null) { @@ -486,7 +517,7 @@ function extractNested( } const own = value["@context"]; // The validator reads a document without a context as ActivityStreams. - if (prepared["@context"] === undefined) chain.unshift(activityStreams); + if (prepared["@context"] === undefined) chain.unshift(ACTIVITY_STREAMS); if (chain.length > 0) { const context = chain.length === 1 ? chain[0]! : chain; if (JSON.stringify(context) !== JSON.stringify(own ?? null)) { diff --git a/packages/federation/src/object-dispatchers.ts b/packages/federation/src/object-dispatchers.ts index 55e803b..1ab095d 100644 --- a/packages/federation/src/object-dispatchers.ts +++ b/packages/federation/src/object-dispatchers.ts @@ -17,21 +17,15 @@ import type { Database } from "@drfed/models"; import { validateUuid } from "@drfed/models/uuid"; import type { FederationBuilder } from "@fedify/fedify"; -import { Object as APObject, Activity, Create, Tombstone } from "@fedify/vocab"; +import { Object as APObject, Activity, Create } from "@fedify/vocab"; -import { - activitySelection, - objectSelection, - toCreate, - toObject, -} from "./object.ts"; -import { canonicalizeAuthority } from "./origin.ts"; -import { publicAddressing, servedActivity } from "./visibility.ts"; +import { findServedActivity, findServedObject } from "./serving/resource.ts"; /** * Registers the dispatchers for local objects and their activities. Saved - * activities other than `Create` are allocated IRIs under `/ap/activities/` - * through `Context.getObjectUri(Activity, { id })`. + * `Create` activities are allocated IRIs under `/ap/creates/` and the other + * activities under `/ap/activities/`, through `Context.getObjectUri()`; each + * route serves only the activities allocated to it. * @param builder The builder to register on. * @param db The database to resolve the objects from. */ @@ -42,30 +36,10 @@ export function registerObjectDispatchers( builder.setObjectDispatcher( APObject, "/users/{identifier}/{id}", - async (ctx, { identifier, id }) => { - if (!validateUuid(identifier) || !validateUuid(id)) return null; - const object = await db.query.objects.findFirst({ - where: { - id, - actorId: identifier, - RAW: (table) => publicAddressing(table.id), - actor: { - localId: { isNotNull: true }, - deleted: { isNull: true }, - instance: { host: canonicalizeAuthority(ctx.host) }, - }, - }, - with: objectSelection, - }); - if (object == null) return null; - if (object.deleted != null) { - return new Tombstone({ - id: new URL(object.resource.iri), - deleted: object.deleted, - }); - } - return toObject(ctx, object); - }, + async (ctx, { identifier, id }) => + validateUuid(identifier) && validateUuid(id) + ? await findServedObject(db, ctx, identifier, id) + : null, ); builder.setObjectDispatcher( @@ -73,26 +47,17 @@ export function registerObjectDispatchers( "/ap/creates/{id}", async (ctx, { id }) => { if (!validateUuid(id)) return null; - const activity = await db.query.activities.findFirst({ - where: { - id, - actor: { - localId: { isNotNull: true }, - deleted: { isNull: true }, - instance: { host: canonicalizeAuthority(ctx.host) }, - }, - RAW: (table) => servedActivity(table), - }, - with: activitySelection, - }); - return activity == null ? null : toCreate(ctx, activity); + const activity = await findServedActivity(db, ctx, id, "create"); + return activity instanceof Create ? activity : null; }, ); - // FIXME: https://github.com/fedify-dev/drfed/issues/153 builder.setObjectDispatcher( Activity, "/ap/activities/{id}", - () => null, + async (ctx, { id }) => + validateUuid(id) + ? await findServedActivity(db, ctx, id, "activity") + : null, ); } diff --git a/packages/federation/src/serving-fixture.test.ts b/packages/federation/src/serving-fixture.test.ts new file mode 100644 index 0000000..3783af1 --- /dev/null +++ b/packages/federation/src/serving-fixture.test.ts @@ -0,0 +1,115 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { JsonObject } from "@drfed/federation/local-write"; +import { validateDocument } from "@drfed/models/document"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import * as vocabulary from "@fedify/vocab"; + +const AS = "https://www.w3.org/ns/activitystreams"; + +/** + * `Activity`, `IntransitiveActivity` and every activity type the + * ActivityStreams vocabulary names. The same list as `activityTypes` in + * *packages/models/src/resource-revision.test.ts*, which is not built and so + * cannot be imported here; change the two together. A test compares this + * one with {@link acceptedActivityTypeIds}. + */ +export const ACTIVITY_TYPES = [ + "Activity", + "IntransitiveActivity", + "Accept", + "Add", + "Announce", + "Arrive", + "Block", + "Create", + "Delete", + "Dislike", + "Flag", + "Follow", + "Ignore", + "Invite", + "Join", + "Leave", + "Like", + "Listen", + "Move", + "Offer", + "Question", + "Read", + "Reject", + "Remove", + "TentativeAccept", + "TentativeReject", + "Travel", + "Undo", + "Update", + "View", +] as const; + +export type ActivityType = (typeof ACTIVITY_TYPES)[number]; + +/** The blind recipient every fixture is addressed to. */ +export const BLIND_RECIPIENT = "https://remote.example/users/blind"; + +/** + * The extension value every fixture carries: an array whose order and + * repeated values matter, `false` and `null`. `-0` is left out, since a + * JSON round trip makes it `0`. + */ +export const EXTENSION_VALUE: JsonObject = { + ordered: [2, 1, 2], + enabled: false, + empty: null, +}; + +const isActivityClass = (value: unknown): value is typeof vocabulary.Activity => + value === vocabulary.Activity || + (typeof value === "function" && + value.prototype instanceof vocabulary.Activity); + +/** + * Lists the type IRIs of the Fedify activity classes that the validator + * accepts, so that a test can tell when {@link ACTIVITY_TYPES} falls behind + * it. + * @returns The IRIs, sorted. + */ +export const acceptedActivityTypeIds = (): string[] => + Object.values(vocabulary) + .filter(isActivityClass) + .map(({ typeId }) => typeId.href) + .filter((type) => + validateDocument({ "@context": AS, type }).diagnostics.every( + ({ reason }) => reason !== "unsupported_type", + ), + ) + .toSorted(); + +/** + * Makes the document saved for an activity type: Public, with a blind + * recipient and an extension property, and nothing a type requires, since + * the validator requires nothing of any of these types. + * @param type The activity type. + * @returns A new document. + */ +export const typeDocument = (type: ActivityType): JsonObject => ({ + "@context": [AS, { test: "https://example.test/vocab#" }], + type, + to: [PUBLIC_IRI], + bto: [BLIND_RECIPIENT], + "test:extension": structuredClone(EXTENSION_VALUE), +}); diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts new file mode 100644 index 0000000..64ae5ea --- /dev/null +++ b/packages/federation/src/serving.test.ts @@ -0,0 +1,1808 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Writes in one test depend on the state the previous ones left. +// oxlint-disable no-await-in-loop + +import assert from "node:assert/strict"; + +import { + type JsonObject, + type LocalWriteEnvironment, + type LocalWriteRequest, + type NewResource, + type SavedResource, + commitLocalWrite, + createLegacyObject, + isLocalWriteError, + previewLocalWrite, +} from "@drfed/federation/local-write"; +import { + loadActivitySnapshot, + parseVocab, + publicDocument, +} from "@drfed/federation/serving"; +import { type Database, schema } from "@drfed/models"; +import { + PUBLIC_IRI, + addActorCollectionItem, + ensureResource, + storeAddressingOccurrences, +} from "@drfed/models/resource"; +import { + appendResourceRevision, + loadCurrentResourceRevision, +} from "@drfed/models/resource-revision"; +import { type Uuid, uuidV7 } from "@drfed/models/uuid"; +import type { Federation } from "@fedify/fedify"; +import { Like, OrderedCollectionPage, getTypeId } from "@fedify/vocab"; +import { describe, it } from "@logtape/testing-node/autoload"; +import { eq } from "drizzle-orm"; + +import { withFederation } from "./harness.test.ts"; +import { + created, + expires, + localActorId, + localInstanceId, + seedActors, + seedLocalActor, +} from "./seed.test.ts"; +import { + ACTIVITY_TYPES, + BLIND_RECIPIENT, + EXTENSION_VALUE, + acceptedActivityTypeIds, + typeDocument, +} from "./serving-fixture.test.ts"; + +const AS = "https://www.w3.org/ns/activitystreams"; +const ACCOUNT_ID = "00000000-0000-4000-8000-000000000301" as Uuid; +const PRINCIPAL = { accountId: ACCOUNT_ID }; +const HOST = "test-instance.drfed.org"; +const ACTOR_IRI = `https://${HOST}/users/${localActorId}`; +const BLIND = "https://remote.example/users/blind"; +const BOB = "https://remote.example/users/bob"; + +type Harness = Parameters[0]>[0]; + +async function setUp(harness: Harness): Promise { + await seedLocalActor(harness.db, { keys: false }); + await harness.db.insert(schema.accounts).values({ + id: ACCOUNT_ID, + email: "member@example.com", + name: "Member", + created, + }); + await harness.db.insert(schema.instanceMembers).values({ + accountId: ACCOUNT_ID, + instanceId: localInstanceId, + accepted: created, + created, + }); + return { + db: harness.db, + federation: harness.federation, + rootOrigin: new URL("https://drfed.org"), + previewKey: await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ), + }; +} + +async function write( + env: LocalWriteEnvironment, + request: LocalWriteRequest, +): Promise { + const preview = await previewLocalWrite(env, PRINCIPAL, request); + assert.ok(preview.ok, JSON.stringify(!preview.ok && preview.error)); + const outcome = await commitLocalWrite(env, PRINCIPAL, request, { + previewToken: preview.preview.token, + reserveDispatch: () => Promise.resolve(), + }); + assert.ok(outcome.ok, JSON.stringify(!outcome.ok && outcome.error)); + return outcome.result.resources; +} + +/** + * Saves a document as a new root resource, with optional nested resources. + * @returns The saved root, last of the saved resources. + */ +async function save( + env: LocalWriteEnvironment, + document: JsonObject, + kind: NewResource["kind"], + options: { nested?: NewResource[]; publish?: boolean } = {}, +): Promise { + const resources = await write(env, { + operation: "create", + actorId: localActorId, + document, + resources: [{ path: [], kind }, ...(options.nested ?? [])], + publish: options.publish ?? kind === "activity", + }); + return resources.at(-1)!; +} + +async function head(db: Database, id: Uuid) { + const revision = await loadCurrentResourceRevision(db, id); + assert.ok(revision != null); + return revision; +} + +async function edit( + env: LocalWriteEnvironment, + objectId: Uuid, + change: (document: JsonObject) => JsonObject, +): Promise { + const current = await head(env.db, objectId); + const [, update] = await write(env, { + operation: "edit", + actorId: localActorId, + objectId, + expectedRevisionId: current.id, + document: change(current.document as JsonObject), + }); + return update!; +} + +async function remove( + env: LocalWriteEnvironment, + objectId: Uuid, +): Promise { + const current = await head(env.db, objectId); + const [deletion] = await write(env, { + operation: "delete", + actorId: localActorId, + objectId, + expectedRevisionId: current.id, + }); + return deletion!; +} + +async function get( + federation: Federation, + iri: string, +): Promise<{ status: number; body: Record }> { + const response = await federation.fetch( + new Request(iri, { headers: { accept: "application/activity+json" } }), + { contextData: undefined }, + ); + // Fedify answers a Tombstone with 410 and the document as its body. + return { + status: response.status, + body: response.ok || response.status === 410 ? await response.json() : {}, + }; +} + +/** + * Requests a resource away from its live actor's instance: on a host that is + * no instance, on another instance's authority, or after the actor is + * deleted. + * @returns The IRI each resource is then requested at. + */ +async function misdirect( + harness: Harness, + scenario: "wrongHost" | "otherInstance" | "deletedActor", +): Promise<(iri: string) => string> { + if (scenario === "wrongHost") { + return (iri) => iri.replace(HOST, "wrong.example"); + } + if (scenario === "otherInstance") { + const id = uuidV7(); + await harness.db + .insert(schema.localInstances) + .values({ id, slug: "other-instance", expires }); + await harness.db.insert(schema.instances).values({ + id, + localId: id, + created, + host: "other-instance.drfed.org", + }); + return (iri) => iri.replace(HOST, "other-instance.drfed.org"); + } + await harness.db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + return (iri) => iri; +} + +/** + * Finds every key spelled `bto` or `bcc` anywhere in a document. + * @returns The keys, in document order. + */ +function blindKeys(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(blindKeys); + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => [ + ...(key === "bto" || key === "bcc" ? [key] : []), + ...blindKeys(entry), + ]); +} + +describe("publicDocument()", () => { + it("removes blind recipients and keeps the other addressing in order", () => { + const document = { + "@context": AS, + id: "https://a.example/notes/1", + type: "Note", + content: "x", + to: [PUBLIC_IRI, BOB], + bto: [BLIND], + cc: [`${ACTOR_IRI}/followers`], + bcc: BLIND, + audience: "https://a.example/groups/1", + }; + const copy = structuredClone(document); + assert.deepEqual(publicDocument(document), { + "@context": AS, + id: "https://a.example/notes/1", + type: "Note", + content: "x", + to: [PUBLIC_IRI, BOB], + cc: [`${ACTOR_IRI}/followers`], + audience: "https://a.example/groups/1", + }); + assert.deepEqual(document, copy); + }); + + it("removes blind recipients from embedded documents at every depth", () => { + const document = { + "@context": AS, + id: "https://a.example/activities/1", + type: "Undo", + actor: ACTOR_IRI, + to: [PUBLIC_IRI], + bcc: [BLIND], + object: { + id: "https://a.example/activities/2", + type: "Like", + actor: ACTOR_IRI, + bto: [BLIND], + object: { + id: "https://a.example/notes/1", + type: "Note", + content: "x", + bcc: [BLIND], + }, + }, + }; + const result = publicDocument(document); + assert.deepEqual(blindKeys(result), []); + assert.deepEqual(result, { + "@context": AS, + id: "https://a.example/activities/1", + type: "Undo", + actor: ACTOR_IRI, + to: [PUBLIC_IRI], + object: { + id: "https://a.example/activities/2", + type: "Like", + actor: ACTOR_IRI, + object: { + id: "https://a.example/notes/1", + type: "Note", + content: "x", + }, + }, + }); + }); + + it("removes blind recipients by meaning, whatever their spelling", () => { + const context = [ + AS, + { + hidden: { "@id": "as:bcc", "@type": "@id" }, + secret: { "@id": `${AS}#bto`, "@type": "@id" }, + }, + ]; + const result = publicDocument({ + "@context": context, + id: "https://a.example/notes/1", + type: "Note", + content: "x", + to: [PUBLIC_IRI], + hidden: [BLIND], + secret: BLIND, + }); + assert.deepEqual(result, { + "@context": context, + id: "https://a.example/notes/1", + type: "Note", + content: "x", + to: [PUBLIC_IRI], + }); + }); + + it("keeps the context and extension values without their blind recipients", () => { + const context = [ + AS, + { ex: "https://ext.example/ns#", mood: "ex:mood" }, + ] as const; + const literal = { "@type": "@json", "@value": { bcc: ["kept"] } }; + const document = { + "@context": [...context], + id: "https://a.example/activities/1", + type: "Like", + actor: ACTOR_IRI, + object: "https://remote.example/notes/1", + mood: { bcc: [BLIND], deep: [1, "x", null, { bto: BLIND }], literal }, + bcc: [BLIND], + }; + assert.deepEqual(publicDocument(document), { + "@context": [...context], + id: "https://a.example/activities/1", + type: "Like", + actor: ACTOR_IRI, + object: "https://remote.example/notes/1", + mood: { deep: [1, "x", null, {}], literal }, + }); + // Blind recipients are found by IRI, whatever their container, and a + // JSON literal stays as saved, however its keywords are spelled. + const listed = [ + ...context, + { + hidden: { "@id": "as:bcc", "@type": "@id", "@container": "@list" }, + }, + ]; + const typed = { type: "@json", "@value": { bcc: ["kept"], "@id": 1 } }; + assert.deepEqual( + publicDocument({ + ...document, + "@context": listed, + mood: { hidden: [BLIND], typed }, + }), + { + "@context": listed, + id: "https://a.example/activities/1", + type: "Like", + actor: ACTOR_IRI, + object: "https://remote.example/notes/1", + mood: { typed }, + }, + ); + // The validator does not read a context inside an extension value. + assert.equal( + publicDocument({ + ...document, + mood: { "@context": { secret: "as:bcc" }, secret: [BLIND] }, + }), + null, + ); + }); + + it("refuses a document the validator cannot read offline", () => { + assert.equal( + publicDocument({ + "@context": [AS, "https://gotosocial.org/ns"], + id: "https://a.example/notes/1", + type: "Note", + content: "x", + bcc: [BLIND], + }), + null, + ); + }); +}); + +describe("federation reads of saved activities", () => { + const fixtures: { + name: string; + document: JsonObject; + nested?: NewResource[]; + route: "creates" | "activities"; + check?: (body: Record) => void; + }[] = [ + { + name: "Create with an embedded Note", + document: { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + bcc: [BLIND], + object: { type: "Note", content: "x", to: [PUBLIC_IRI], bcc: [BLIND] }, + }, + nested: [{ path: ["object"], kind: "object" }], + route: "creates", + check: (body) => assert.equal(typeof body.object, "object"), + }, + { + name: "Create referring to a new Note", + document: { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "x", to: [PUBLIC_IRI] }, + }, + nested: [ + { path: ["object"], kind: "object", representation: "reference" }, + ], + route: "creates", + check: (body) => + assert.ok( + String(body.object).startsWith(`${ACTOR_IRI}/`), + String(body.object), + ), + }, + { + name: "Undo of a new Like of a remote Note", + document: { + "@context": AS, + type: "Undo", + to: [PUBLIC_IRI], + object: { type: "Like", object: "https://remote.example/notes/1" }, + }, + nested: [{ path: ["object"], kind: "activity" }], + route: "activities", + check: (body) => + assert.equal( + (body.object as { object: unknown }).object, + "https://remote.example/notes/1", + ), + }, + { + name: "Accept of a remote Follow", + document: { + "@context": AS, + type: "Accept", + to: [BOB], + cc: [PUBLIC_IRI], + object: { + id: "https://remote.example/follows/1", + type: "Follow", + actor: BOB, + object: ACTOR_IRI, + }, + }, + route: "activities", + check: (body) => + assert.equal((body.object as { actor: unknown }).actor, BOB), + }, + { + name: "Add to a collection target", + document: { + "@context": AS, + type: "Add", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + target: `${ACTOR_IRI}/featured`, + }, + route: "activities", + check: (body) => assert.equal(body.target, `${ACTOR_IRI}/featured`), + }, + { + name: "Arrive without an object", + document: { + "@context": AS, + type: "Arrive", + to: [PUBLIC_IRI], + location: "https://remote.example/places/1", + }, + route: "activities", + check: (body) => { + assert.equal(body.object, undefined); + assert.equal(body.location, "https://remote.example/places/1"); + }, + }, + { + name: "Like with an extension property", + document: { + "@context": [AS, { ex: "https://ext.example/ns#", mood: "ex:mood" }], + type: "Like", + to: [PUBLIC_IRI], + bto: [BLIND], + object: "https://remote.example/notes/1", + mood: { deep: [1, "x"] }, + }, + route: "activities", + check: (body) => assert.deepEqual(body.mood, { deep: [1, "x"] }), + }, + { + name: "Question saved as an activity", + document: { + "@context": AS, + type: "Question", + name: "q", + to: [PUBLIC_IRI], + }, + route: "activities", + }, + ]; + for (const { name, document, nested, route, check } of fixtures) { + it(`serves ${name} as saved, without blind recipients`, async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const saved = await save(env, document, "activity", { + ...(nested == null ? {} : { nested }), + }); + assert.ok( + saved.iri.startsWith(`https://${HOST}/ap/${route}/`), + saved.iri, + ); + const { status, body } = await get(harness.federation, saved.iri); + assert.equal(status, 200); + assert.deepEqual(body, publicDocument(saved.document)); + assert.deepEqual(blindKeys(body), []); + check?.(body); + const other = saved.iri.replace( + `/ap/${route}/`, + route === "creates" ? "/ap/activities/" : "/ap/creates/", + ); + assert.equal((await get(harness.federation, other)).status, 404); + }); + }); + } + + it("covers every activity type the validator accepts", () => { + assert.deepEqual( + acceptedActivityTypeIds(), + ACTIVITY_TYPES.map((type) => `${AS}#${type}`).toSorted(), + ); + }); + + it("serves every activity type as saved, at its route and in the outbox", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const ctx = harness.federation.createContext( + new URL(`https://${HOST}/`), + undefined, + ); + const iris: string[] = []; + for (const type of ACTIVITY_TYPES) { + const saved = await save(env, typeDocument(type), "activity"); + const route = type === "Create" ? "creates" : "activities"; + assert.ok( + saved.iri.startsWith(`https://${HOST}/ap/${route}/`), + `${type}: ${saved.iri}`, + ); + // Stored with its blind recipient, so that serving has one to remove. + const stored = await loadActivitySnapshot(harness.db, saved.id); + assert.ok(stored != null, type); + assert.deepEqual(stored.document, saved.document, type); + assert.deepEqual(stored.document.bto, [BLIND_RECIPIENT], type); + const served = publicDocument(stored.document); + assert.ok(served != null, type); + const { status, body } = await get(harness.federation, saved.iri); + assert.equal(status, 200, type); + assert.deepEqual(body, served, type); + assert.equal(body.type, type); + assert.deepEqual(blindKeys(body), [], type); + assert.deepEqual(body["test:extension"], EXTENSION_VALUE, type); + const other = saved.iri.replace( + `/ap/${route}/`, + route === "creates" ? "/ap/activities/" : "/ap/creates/", + ); + assert.equal((await get(harness.federation, other)).status, 404, type); + // The parsing outgoing payloads use reads every type as itself. + const vocab = await parseVocab(ctx, served); + assert.equal(getTypeId(vocab).href, `${AS}#${type}`); + assert.deepEqual(await vocab.toJsonLd(ctx), served, type); + iris.push(saved.iri); + } + // Both routes share one outbox, newest first, across its pages. + const ids = (body: Record) => + (body.orderedItems as Record[]).map(({ id }) => id); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); + assert.equal(collection.body.totalItems, ACTIVITY_TYPES.length); + const first = await get( + harness.federation, + `${ACTOR_IRI}/outbox?cursor=`, + ); + assert.equal(typeof first.body.next, "string"); + const last = await get(harness.federation, String(first.body.next)); + assert.equal(last.body.next, undefined); + assert.deepEqual( + [...ids(first.body), ...ids(last.body)], + iris.toReversed(), + ); + }); + }); + + it("serializes a saved activity the same way for other uses", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const saved = await save(env, fixtures[6]!.document, "activity"); + const snapshot = await loadActivitySnapshot(harness.db, saved.id); + assert.equal(snapshot?.revisionId, saved.revisionId); + assert.deepEqual(snapshot?.document, saved.document); + const served = publicDocument(snapshot!.document)!; + const ctx = harness.federation.createContext( + new URL(`https://${HOST}/`), + undefined, + ); + const vocab = await parseVocab(ctx, served); + assert.ok(vocab instanceof Like); + assert.deepEqual(await vocab.toJsonLd(ctx), served); + const note = await save( + env, + { "@context": AS, type: "Note", content: "x" }, + "object", + ); + assert.equal(await loadActivitySnapshot(harness.db, note.id), null); + }); + }); + + for (const scenario of [ + "unpublished", + "followers", + "wrongHost", + "otherInstance", + "deletedActor", + "malformed", + ] as const) { + it(`does not serve ${scenario} saved activities`, async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const saved = await save( + env, + { + "@context": AS, + type: "Like", + object: "https://remote.example/notes/1", + to: [ + scenario === "followers" ? `${ACTOR_IRI}/followers` : PUBLIC_IRI, + ], + }, + "activity", + { publish: scenario !== "unpublished" }, + ); + const iri = + scenario === "malformed" + ? `https://${HOST}/ap/activities/bad` + : scenario === "unpublished" || scenario === "followers" + ? saved.iri + : (await misdirect(harness, scenario))(saved.iri); + assert.equal((await get(harness.federation, iri)).status, 404); + }); + }); + } +}); + +describe("federation reads of saved objects and their history", () => { + it("serves a saved object as saved, and only a Public one", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const note = { + "@context": AS, + type: "Note", + content: "x", + to: [PUBLIC_IRI], + bcc: [BLIND], + }; + const saved = await save(env, note, "object"); + const { status, body } = await get(harness.federation, saved.iri); + assert.equal(status, 200); + assert.deepEqual(body, publicDocument(saved.document)); + assert.equal(body.bcc, undefined); + // Only the actor the object is attributed to serves it. + const elsewhere = saved.iri.replace(localActorId, uuidV7()); + assert.equal((await get(harness.federation, elsewhere)).status, 404); + const followers = await save( + env, + { ...note, to: [`${ACTOR_IRI}/followers`] }, + "object", + ); + assert.equal((await get(harness.federation, followers.iri)).status, 404); + }); + }); + + it("serves a saved object and its Tombstone at its author's route only", async () => { + await withFederation(async (harness) => { + const OTHER_ID = "00000000-0000-4000-8000-000000000203" as Uuid; + const env = await setUp(harness); + await harness.db + .insert(schema.localActors) + .values({ id: OTHER_ID, avatar: "avatar.png", header: "header.png" }); + await seedActors( + harness.db, + { + id: OTHER_ID, + localId: OTHER_ID, + instanceId: localInstanceId, + type: "Person", + username: "bob", + iri: `https://${HOST}/users/${OTHER_ID}`, + inboxUrl: `https://${HOST}/users/${OTHER_ID}/inbox`, + created, + }, + { keys: false }, + ); + const note = { "@context": AS, type: "Note", to: [PUBLIC_IRI] }; + const kept = await save(env, { ...note, content: "x" }, "object"); + const deleted = await save(env, { ...note, content: "y" }, "object"); + await remove(env, deleted.id); + for (const [saved, status] of [ + [kept, 200], + [deleted, 410], + ] as const) { + assert.equal((await get(harness.federation, saved.iri)).status, status); + // Another live actor of the same instance does not serve it. + const elsewhere = saved.iri.replace(localActorId, OTHER_ID); + assert.equal((await get(harness.federation, elsewhere)).status, 404); + } + }); + }); + + for (const scenario of [ + "wrongHost", + "otherInstance", + "deletedActor", + ] as const) { + it(`does not serve saved objects or their Tombstones for ${scenario}`, async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const note = { "@context": AS, type: "Note", to: [PUBLIC_IRI] }; + const kept = await save(env, { ...note, content: "x" }, "object"); + const deleted = await save(env, { ...note, content: "y" }, "object"); + await remove(env, deleted.id); + assert.equal((await get(harness.federation, kept.iri)).status, 200); + assert.equal((await get(harness.federation, deleted.iri)).status, 410); + const iri = await misdirect(harness, scenario); + for (const saved of [kept, deleted]) { + const { status } = await get(harness.federation, iri(saved.iri)); + assert.equal(status, 404); + } + }); + }); + } + + it("keeps past activities as they were saved when the object changes", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const embedded = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { + type: "Note", + content: "A", + to: [PUBLIC_IRI], + bcc: [BLIND], + }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + const noteIri = (embedded.document.object as { id: string }).id; + const noteId = noteIri.slice(noteIri.lastIndexOf("/") + 1) as Uuid; + const referred = await save( + env, + { "@context": AS, type: "Like", to: [PUBLIC_IRI], object: noteIri }, + "activity", + ); + const update = await edit(env, noteId, (document) => ({ + ...document, + content: "B", + })); + const before = await get(harness.federation, embedded.iri); + assert.equal(before.status, 200); + assert.equal((before.body.object as { content: string }).content, "A"); + assert.equal( + (await get(harness.federation, referred.iri)).body.object, + noteIri, + ); + const served = await get(harness.federation, update.iri); + assert.equal(served.status, 200); + assert.equal(served.body.type, "Update"); + assert.equal((served.body.object as { content: string }).content, "B"); + assert.deepEqual(blindKeys(update.document), ["bcc", "bcc"]); + assert.deepEqual(blindKeys(served.body), []); + assert.equal( + ((await get(harness.federation, noteIri)).body as { content: string }) + .content, + "B", + ); + }); + }); + + it("serves a deleted object as a Tombstone and only the Delete about it", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const create = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { + type: "Note", + content: "A", + to: [PUBLIC_IRI], + bcc: [BLIND], + }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + const noteIri = (create.document.object as { id: string }).id; + const noteId = noteIri.slice(noteIri.lastIndexOf("/") + 1) as Uuid; + const like = await save( + env, + { "@context": AS, type: "Like", to: [PUBLIC_IRI], object: noteIri }, + "activity", + ); + const update = await edit(env, noteId, (document) => ({ + ...document, + content: "B", + })); + const deletion = await remove(env, noteId); + const tombstone = await get(harness.federation, noteIri); + assert.equal(tombstone.status, 410); + const { "@context": _, ...rest } = tombstone.body; + assert.deepEqual(Object.keys(rest).toSorted(), ["deleted", "id", "type"]); + assert.equal(rest.type, "Tombstone"); + assert.equal(rest.id, noteIri); + const whiteout = await head(harness.db, noteId); + assert.equal( + Temporal.Instant.from(String(rest.deleted)).epochNanoseconds, + whiteout.deleted?.epochNanoseconds, + ); + for (const hidden of [create, like, update]) { + assert.equal((await get(harness.federation, hidden.iri)).status, 404); + } + const served = await get(harness.federation, deletion.iri); + assert.equal(served.status, 200); + assert.equal(served.body.type, "Delete"); + assert.equal(served.body.object, noteIri); + assert.deepEqual(blindKeys(served.body), []); + // A followers-only object stays hidden after its deletion as well. + const followers = await save( + env, + { + "@context": AS, + type: "Note", + content: "x", + to: [`${ACTOR_IRI}/followers`], + }, + "object", + ); + await remove(env, followers.id); + assert.equal((await get(harness.federation, followers.iri)).status, 404); + }); + }); + + it("serves the Update and Delete of a legacy object beside its typed rows", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const legacy = await createLegacyObject(env, PRINCIPAL, { + actorId: localActorId, + type: "Note", + contentHtml: "

A

", + name: null, + summary: null, + language: null, + sensitive: false, + addressing: { to: [PUBLIC_IRI], bcc: [BLIND] }, + }); + assert.ok(!isLocalWriteError(legacy)); + const legacyIri = `${ACTOR_IRI}/${legacy.id}`; + const createIri = `https://${HOST}/ap/creates/${legacy.activityId}`; + const original = await get(harness.federation, createIri); + assert.equal(original.status, 200); + const update = await edit(env, legacy.id, (document) => ({ + ...document, + content: "

B

", + cc: [BOB], + })); + const object = await get(harness.federation, legacyIri); + assert.equal(object.body.content, "

B

"); + assert.equal(object.body.cc, BOB); + // Neither the object's content nor its recipients rewrite the Create. + const legacyCreate = await get(harness.federation, createIri); + assert.deepEqual(legacyCreate, original); + // A legacy Create is served at its own route only. + assert.equal( + ( + await get( + harness.federation, + createIri.replace("/ap/creates/", "/ap/activities/"), + ) + ).status, + 404, + ); + assert.equal(legacyCreate.body.object, legacyIri); + const served = await get(harness.federation, update.iri); + assert.equal(served.status, 200); + assert.deepEqual(blindKeys(served.body), []); + const deletion = await remove(env, legacy.id); + assert.equal((await get(harness.federation, legacyIri)).status, 410); + assert.equal((await get(harness.federation, createIri)).status, 404); + assert.equal((await get(harness.federation, update.iri)).status, 404); + const deleted = await get(harness.federation, deletion.iri); + assert.equal(deleted.status, 200); + assert.equal(deleted.body.object, legacyIri); + assert.deepEqual(blindKeys(deleted.body), []); + }); + }); +}); + +describe("embedded documents and implicit contexts", () => { + const outboxIds = async (federation: Federation) => { + const collection = await get(federation, `${ACTOR_IRI}/outbox`); + const page = await get(federation, `${ACTOR_IRI}/outbox?cursor=`); + const items = (page.body.orderedItems ?? []) as Record[]; + assert.equal(collection.body.totalItems, items.length); + return items.map(({ id }) => id); + }; + + it("does not serve a Public activity that embeds content addressed to others", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const restricted = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "secret", to: [BOB] }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + const noteIri = (restricted.document.object as { id: string }).id; + assert.equal((await get(harness.federation, noteIri)).status, 404); + assert.equal((await get(harness.federation, restricted.iri)).status, 404); + const deep = await save( + env, + { + "@context": AS, + type: "Undo", + to: [PUBLIC_IRI], + object: { + type: "Like", + object: { type: "Note", content: "secret", cc: [BOB] }, + }, + }, + "activity", + { + nested: [ + { path: ["object"], kind: "activity" }, + { path: ["object", "object"], kind: "object" }, + ], + }, + ); + assert.equal((await get(harness.federation, deep.iri)).status, 404); + // A document without recipients of its own goes with the activity. + const open = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "open" }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + assert.equal((await get(harness.federation, open.iri)).status, 200); + assert.deepEqual(await outboxIds(harness.federation), [open.iri]); + }); + }); + + it("reads embedded recipients as the addressing index does", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const served: string[] = []; + for (const [to, status] of [ + [[{ type: "Collection", id: PUBLIC_IRI }], 200], + [{ type: "Collection", id: "as:Public" }, 200], + [[{ type: "Link", href: PUBLIC_IRI }], 200], + [[{ type: "Collection", id: `${ACTOR_IRI}/followers` }], 404], + ] as const) { + const create = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "x", to }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + const noteIri = (create.document.object as { id: string }).id; + assert.equal((await get(harness.federation, noteIri)).status, status); + assert.equal( + (await get(harness.federation, create.iri)).status, + status, + ); + if (status === 200) served.unshift(create.iri); + } + // The Note's own collection is not a recipient of the Like around it. + const undo = await save( + env, + { + "@context": AS, + type: "Undo", + to: [PUBLIC_IRI], + object: { + type: "Like", + object: { + type: "Note", + content: "x", + to: PUBLIC_IRI, + cc: { type: "Collection", id: `${ACTOR_IRI}/followers` }, + }, + }, + }, + "activity", + { + nested: [ + { path: ["object"], kind: "activity" }, + { path: ["object", "object"], kind: "object" }, + ], + }, + ); + assert.equal((await get(harness.federation, undo.iri)).status, 200); + assert.deepEqual(await outboxIds(harness.federation), [ + undo.iri, + ...served, + ]); + }); + }); + + it("serves values Fedify does not parse, such as Link recipients and objects", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const note = await save( + env, + { "@context": AS, type: "Note", content: "x", to: [PUBLIC_IRI] }, + "object", + ); + const audience = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + audience: { type: "Link", href: BOB }, + object: "https://remote.example/notes/1", + }, + "activity", + ); + const linked = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object: { type: "Link", href: note.iri }, + }, + "activity", + ); + // A functional property may be a singleton list. + const listed = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object: { type: "Link", href: [note.iri] }, + }, + "activity", + ); + for (const saved of [audience, linked, listed]) { + const { status, body } = await get(harness.federation, saved.iri); + assert.equal(status, 200); + assert.deepEqual(body, publicDocument(saved.document)); + } + const page = await get(harness.federation, `${ACTOR_IRI}/outbox?cursor=`); + assert.deepEqual(page.body.orderedItems, [ + publicDocument(listed.document), + publicDocument(linked.document), + publicDocument(audience.document), + ]); + // A Link names the object it refers to, as a reference does. + const deletion = await remove(env, note.id); + assert.equal((await get(harness.federation, linked.iri)).status, 404); + assert.equal((await get(harness.federation, listed.iri)).status, 404); + assert.deepEqual(await outboxIds(harness.federation), [ + deletion.iri, + audience.iri, + ]); + }); + }); + + it("reads a Public Link recipient whose href is a list of one value", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const served: string[] = []; + // Null stands for no value, so either list has one. + for (const href of [[PUBLIC_IRI], [null, PUBLIC_IRI]]) { + const like = await save( + env, + { + "@context": AS, + type: "Like", + to: { type: "Link", href }, + object: "https://remote.example/notes/1", + }, + "activity", + ); + const { status, body } = await get(harness.federation, like.iri); + assert.equal(status, 200); + assert.deepEqual(body, publicDocument(like.document)); + served.unshift(like.iri); + } + assert.deepEqual(await outboxIds(harness.federation), served); + }); + }); + + it("reads documents that share an id as JSON-LD joins them", async () => { + await withFederation(async (harness) => { + const LINK = "https://remote.example/links/1"; + const env = await setUp(harness); + const note = await save( + env, + { "@context": AS, type: "Note", content: "x", to: [PUBLIC_IRI] }, + "object", + ); + // Two embedded documents with one id are one, which neither tells. + const shared = await save( + env, + { + "@context": AS, + type: "Add", + to: [PUBLIC_IRI], + object: { type: "Link", id: LINK, href: "https://remote.example/n" }, + target: { type: "Link", id: LINK, href: note.iri }, + }, + "activity", + ); + assert.equal((await get(harness.federation, shared.iri)).status, 404); + // A reference names what a Link embedded elsewhere with its id does. + const referred = await save( + env, + { + "@context": AS, + type: "Add", + to: [PUBLIC_IRI], + object: LINK, + target: { type: "Link", id: LINK, href: note.iri }, + }, + "activity", + ); + assert.equal((await get(harness.federation, referred.iri)).status, 200); + const deletion = await remove(env, note.id); + assert.equal((await get(harness.federation, referred.iri)).status, 404); + assert.deepEqual(await outboxIds(harness.federation), [deletion.iri]); + }); + }); + + it("reads a node object in an extension value whose own href is its id", async () => { + await withFederation(async (harness) => { + const SELF = "https://remote.example/x"; + const env = await setUp(harness); + const saved = await save( + env, + { + "@context": [AS, { ex: "https://ext.example/ns#" }], + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + "ex:quoted": { type: "Link", id: SELF, href: SELF }, + }, + "activity", + ); + const { status, body } = await get(harness.federation, saved.iri); + assert.equal(status, 200); + assert.deepEqual(body, publicDocument(saved.document)); + assert.deepEqual(await outboxIds(harness.federation), [saved.iri]); + }); + }); + + it("hides an activity that embeds a deleted object at any depth", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const undo = await save( + env, + { + "@context": AS, + type: "Undo", + to: [PUBLIC_IRI], + object: { + type: "Like", + object: { type: "Note", content: "gone", to: [PUBLIC_IRI] }, + }, + }, + "activity", + { + nested: [ + { path: ["object"], kind: "activity" }, + { path: ["object", "object"], kind: "object" }, + ], + }, + ); + const noteIri = (undo.document.object as { object: { id: string } }) + .object.id; + assert.equal((await get(harness.federation, undo.iri)).status, 200); + const deletion = await remove( + env, + noteIri.slice(noteIri.lastIndexOf("/") + 1) as Uuid, + ); + assert.equal((await get(harness.federation, undo.iri)).status, 404); + assert.deepEqual(await outboxIds(harness.federation), [deletion.iri]); + }); + }); + + it("hides an activity embedding another about a deleted object, unless a Delete", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const note = await save( + env, + { "@context": AS, type: "Note", content: "x", to: [PUBLIC_IRI] }, + "object", + ); + const undo = await save( + env, + { + "@context": AS, + type: "Undo", + to: [PUBLIC_IRI], + object: { type: "Like", object: note.iri }, + }, + "activity", + { nested: [{ path: ["object"], kind: "activity" }] }, + ); + const announce = await save( + env, + { + "@context": AS, + type: "Announce", + to: [PUBLIC_IRI], + object: { + id: "https://remote.example/deletes/1", + type: "Delete", + actor: BOB, + object: note.iri, + }, + }, + "activity", + ); + for (const saved of [undo, announce]) { + assert.equal((await get(harness.federation, saved.iri)).status, 200); + } + const deletion = await remove(env, note.id); + assert.equal((await get(harness.federation, undo.iri)).status, 404); + assert.equal((await get(harness.federation, announce.iri)).status, 200); + assert.deepEqual(await outboxIds(harness.federation), [ + deletion.iri, + announce.iri, + ]); + }); + }); + + it("applies the rules of embedded documents to node objects in extension values", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = (quoted: JsonObject | JsonObject[]): JsonObject => ({ + "@context": [ + AS, + { + ex: "https://ext.example/ns#", + toMap: { "@id": "as:to", "@container": "@language" }, + labels: { "@id": "ex:labels", "@container": "@language" }, + _: `${AS}#`, + }, + ], + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + "ex:quoted": quoted, + }); + const open = await save( + env, + like({ + type: "Note", + content: "open", + to: [PUBLIC_IRI], + bcc: [BLIND], + attachment: { + type: "Note", + content: "deep", + to: PUBLIC_IRI, + bto: BLIND, + }, + }), + "activity", + ); + const served = await get(harness.federation, open.iri); + assert.equal(served.status, 200); + assert.deepEqual(served.body, publicDocument(open.document)); + assert.deepEqual(blindKeys(open.document), ["bcc", "bto"]); + assert.deepEqual(blindKeys(served.body), []); + // A blank node identifier and a language map stay readable. + const readable: string[] = []; + for (const quoted of [ + { id: "_:b0", type: "Note", content: "open", to: [PUBLIC_IRI] }, + { + type: "Note", + content: "open", + to: [PUBLIC_IRI], + labels: { id: "Halo", bcc: "Hello" }, + }, + ]) { + const saved = await save(env, like(quoted), "activity"); + const response = await get(harness.federation, saved.iri); + assert.equal(response.status, 200); + assert.deepEqual(response.body, publicDocument(saved.document)); + readable.unshift(saved.iri); + } + const hidden: SavedResource[] = []; + for (const quoted of [ + { type: "Note", content: "secret", to: [BOB], bcc: [BLIND] }, + { + type: "Note", + content: "secret", + to: { id: `${ACTOR_IRI}/followers` }, + }, + { type: "Note", content: "secret", to: { "@set": [BOB] } }, + { type: "Note", content: "secret", to: { "@set": [{ id: BOB }] } }, + { type: "Note", content: "secret", to: { "@value": PUBLIC_IRI } }, + { + type: "Note", + content: "secret", + to: { type: "Link", id: PUBLIC_IRI, href: BOB }, + }, + // A literal is no recipient, nor is a value read with another context. + { type: "Note", content: "secret", to: BOB, [`${AS}#to`]: PUBLIC_IRI }, + { + type: "Note", + content: "secret", + to: { + "@context": { as: "https://a.example/" }, + "@set": ["as:Public"], + }, + }, + // A key with a @language container holds node objects in a list. + { + type: "Note", + content: "x", + to: [PUBLIC_IRI], + labels: [{ type: "Note", content: "secret", to: [BOB] }], + }, + // An object in a language map is no valid JSON-LD. + { + type: "Note", + content: "x", + to: [PUBLIC_IRI], + labels: { en: { bcc: [BLIND] } }, + }, + // A node object whose id is used elsewhere is not read apart. + { id: "https://remote.example/notes/1", type: "Note", to: [BOB] }, + // A blank node is no compact IRI, whatever prefix "_" names. + { type: "Note", content: "secret", to: [BOB, "_:Public"] }, + // Only a Link names a recipient by its href. + { + type: "Note", + content: "secret", + to: [BOB], + cc: { type: "Note", href: PUBLIC_IRI }, + }, + // A value is a scalar or a JSON literal, and reverse properties are + // not read. + { "@value": { type: "Note", to: [BOB], bcc: [BLIND], content: "x" } }, + { + id: BOB, + "@reverse": { attributedTo: { type: "Note", content: "x" } }, + }, + // A value with keys a context of its own may alias is not read, + // nor is a list with properties, which is no valid JSON-LD. + { + "@context": { kind: "@type" }, + kind: "@json", + "@value": { bcc: "data" }, + }, + { + type: "Note", + content: "x", + to: [PUBLIC_IRI], + cc: { "@set": [], bcc: BLIND }, + }, + // Properties nested with @nest are not read. + { + type: "Note", + content: "secret", + to: [PUBLIC_IRI], + "@nest": { cc: [BOB] }, + }, + // A relative reference names no resource that can be checked. + { type: "Note", id: "/notes/1", content: "secret" }, + // A language map holds no recipients, and a Link has one href. + { type: "Note", content: "secret", to: BOB, toMap: { id: PUBLIC_IRI } }, + { + type: "Like", + object: { + type: "Link", + href: ["https://remote.example/notes/1", `${ACTOR_IRI}/notes/1`], + }, + }, + { "@context": { secret: "as:bcc" }, content: "x", secret: [BLIND] }, + ]) { + hidden.push(await save(env, like(quoted), "activity")); + } + for (const saved of hidden) { + assert.equal((await get(harness.federation, saved.iri)).status, 404); + } + // A deleted object found in the second lookup hides the activity too. + const note = await save( + env, + { "@context": AS, type: "Note", content: "x", to: [PUBLIC_IRI] }, + "object", + ); + const quoting = await save( + env, + like([ + ...Array.from({ length: 1000 }, (_, index) => ({ + id: `https://remote.example/quoted/${index}`, + })), + { id: note.iri, type: "Note", content: "x" }, + ]), + "activity", + ); + // So does an activity about it, unless that is its Delete. + const liking = await save( + env, + like({ type: "Like", actor: BOB, object: note.iri }), + "activity", + ); + const deleting = await save( + env, + like({ type: "Delete", actor: ACTOR_IRI, object: note.iri }), + "activity", + ); + const blankDeleting = await save( + env, + like({ type: "_:Delete", actor: ACTOR_IRI, object: note.iri }), + "activity", + ); + const linking = await save( + env, + like({ + type: "Like", + actor: BOB, + object: { + type: "Link", + id: "https://remote.example/links/1", + href: note.iri, + }, + }), + "activity", + ); + for (const saved of [quoting, liking, deleting, blankDeleting, linking]) { + assert.equal((await get(harness.federation, saved.iri)).status, 200); + } + const deletion = await remove(env, note.id); + assert.equal((await get(harness.federation, quoting.iri)).status, 404); + assert.equal((await get(harness.federation, liking.iri)).status, 404); + assert.equal((await get(harness.federation, linking.iri)).status, 404); + assert.equal( + (await get(harness.federation, blankDeleting.iri)).status, + 404, + ); + assert.equal((await get(harness.federation, deleting.iri)).status, 200); + assert.deepEqual(await outboxIds(harness.federation), [ + deletion.iri, + deleting.iri, + ...readable, + open.iri, + ]); + }); + }); + + it("serves a document saved without a context with the ActivityStreams one", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = await save( + env, + { + type: "Like", + to: [PUBLIC_IRI], + bcc: [BLIND], + object: "https://remote.example/notes/1", + }, + "activity", + ); + assert.equal(like.document["@context"], undefined); + const { bcc: _, ...rest } = like.document; + const expected = { "@context": AS, ...rest }; + assert.deepEqual(publicDocument(like.document), expected); + const served = await get(harness.federation, like.iri); + assert.equal(served.status, 200); + assert.deepEqual(served.body, expected); + assert.deepEqual(await outboxIds(harness.federation), [like.iri]); + const note = await save( + env, + { type: "Note", content: "x", to: [PUBLIC_IRI] }, + "object", + ); + const object = await get(harness.federation, note.iri); + assert.equal(object.status, 200); + assert.equal(object.body["@context"], AS); + }); + }); +}); + +describe("outboxes of saved and legacy activities", () => { + it("pages and counts both in one order, leaving out what is not served", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const legacy = await createLegacyObject(env, PRINCIPAL, { + actorId: localActorId, + type: "Note", + contentHtml: "

A

", + name: null, + summary: null, + language: null, + sensitive: false, + addressing: { to: [PUBLIC_IRI] }, + }); + assert.ok(!isLocalWriteError(legacy)); + const like = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + bcc: [BLIND], + object: "https://remote.example/notes/1", + }, + "activity", + ); + await save( + env, + { "@context": AS, type: "Like", to: [PUBLIC_IRI], object: like.iri }, + "activity", + { publish: false }, + ); + await save( + env, + { + "@context": AS, + type: "Like", + to: [`${ACTOR_IRI}/followers`], + object: "https://remote.example/notes/2", + }, + "activity", + ); + const create = await save( + env, + { + "@context": AS, + type: "Create", + to: [PUBLIC_IRI], + object: { type: "Note", content: "x", to: [PUBLIC_IRI] }, + }, + "activity", + { nested: [{ path: ["object"], kind: "object" }] }, + ); + const noteIri = (create.document.object as { id: string }).id; + const deletion = await remove( + env, + noteIri.slice(noteIri.lastIndexOf("/") + 1) as Uuid, + ); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); + const page = await get(harness.federation, `${ACTOR_IRI}/outbox?cursor=`); + assert.equal(page.status, 200); + const items = page.body.orderedItems as Record[]; + assert.deepEqual( + items.map(({ id }) => id), + [ + deletion.iri, + like.iri, + `https://${HOST}/ap/creates/${legacy.activityId}`, + ], + ); + assert.equal(collection.body.totalItems, items.length); + assert.deepEqual(items[1], publicDocument(like.document)); + assert.deepEqual(blindKeys(items), []); + assert.equal(page.body.next, undefined); + }); + }); + + // FIXME: https://github.com/fedify-dev/drfed/issues/178 + it( + "pages an activity whose context redefines a protected term as JSON-LD", + { + expectFailure: (error: unknown) => + (error as { details?: { code?: unknown } } | null)?.details?.code === + "protected term redefinition", + }, + async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const plain = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + }, + "activity", + ); + const redefining = await save( + env, + { + "@context": [AS, { proof: "https://example.org/proof" }], + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/2", + }, + "activity", + ); + assert.equal( + (await get(harness.federation, redefining.iri)).status, + 200, + ); + const page = await get( + harness.federation, + `${ACTOR_IRI}/outbox?cursor=`, + ); + assert.equal(page.status, 200); + // A JSON-LD client reads the page, not only its JSON. + const parsed = await OrderedCollectionPage.fromJsonLd(page.body); + const ids: (string | undefined)[] = []; + for await (const item of parsed.getItems()) ids.push(item.id?.href); + assert.deepEqual(ids, [redefining.iri, plain.iri]); + }); + }, + ); + + it("leaves out an activity whose saved document cannot be served", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + }, + "activity", + ); + // Documents the write path would refuse, stored as if edited by hand. + const broken: string[] = []; + for (const document of [ + { + "@context": [AS, "https://gotosocial.org/ns"], + type: "Like", + object: "https://remote.example/notes/2", + }, + { + "@context": AS, + id: "https://remote.example/activities/1", + type: "Like", + object: "https://remote.example/notes/3", + }, + ]) { + const id = uuidV7(); + const iri = `https://${HOST}/ap/activities/${id}`; + await harness.db.transaction(async (tx) => { + await ensureResource(tx, iri, id); + await appendResourceRevision(tx, { + resourceId: id, + resourceKind: "activity", + expectedRevisionId: null, + kind: "snapshot", + document: { + id: iri, + actor: ACTOR_IRI, + to: [PUBLIC_IRI], + ...document, + }, + capture: "local", + }); + await storeAddressingOccurrences(tx, id, [ + { property: "to", position: 0, iri: PUBLIC_IRI }, + ]); + await addActorCollectionItem(tx, localActorId, "outbox", id); + }); + broken.push(iri); + } + for (const iri of broken) { + assert.equal((await get(harness.federation, iri)).status, 404); + } + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); + const page = await get(harness.federation, `${ACTOR_IRI}/outbox?cursor=`); + assert.equal(page.status, 200); + assert.deepEqual( + (page.body.orderedItems as Record[]).map( + ({ id }) => id, + ), + [like.iri], + ); + assert.equal(collection.body.totalItems, 1); + }); + }); + + it("fills pages past activities their documents leave out", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = (object: JsonObject | string): JsonObject => ({ + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object, + }); + const oldest = await save( + env, + like("https://remote.example/n"), + "activity", + ); + // More hidden activities than several batches and a counter batch + // hold, between served ones. + for (let index = 0; index < 120; index += 1) { + await save( + env, + like({ type: "Note", content: "secret", to: [BOB] }), + "activity", + ); + } + const newest: string[] = []; + for (let index = 0; index < 20; index += 1) { + const saved = await save( + env, + like(`https://remote.example/notes/${index}`), + "activity", + ); + newest.unshift(saved.iri); + } + const ids = (body: Record) => + (body.orderedItems as Record[]).map(({ id }) => id); + const first = await get( + harness.federation, + `${ACTOR_IRI}/outbox?cursor=`, + ); + assert.deepEqual(ids(first.body), newest); + assert.equal(typeof first.body.next, "string"); + const last = await get(harness.federation, String(first.body.next)); + assert.deepEqual(ids(last.body), [oldest.iri]); + assert.equal(last.body.next, undefined); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); + assert.equal(collection.body.totalItems, 21); + }); + }); + + it("serves only the outbox items its pages can reach", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = await save( + env, + { + "@context": AS, + type: "Like", + to: [PUBLIC_IRI], + object: "https://remote.example/notes/1", + }, + "activity", + ); + await harness.db + .update(schema.collectionItems) + .set({ position: null }) + .where(eq(schema.collectionItems.itemId, like.id)); + assert.equal((await get(harness.federation, like.iri)).status, 404); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); + assert.equal(collection.body.totalItems, 0); + }); + }); +}); diff --git a/packages/federation/src/serving.ts b/packages/federation/src/serving.ts new file mode 100644 index 0000000..7a40e4f --- /dev/null +++ b/packages/federation/src/serving.ts @@ -0,0 +1,30 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/** + * The serialization of saved resources that federation reads use, for + * outgoing payloads to share: a saved document is served as it was saved, + * without blind recipients in it or in any document it embeds. It can be + * parsed into a Fedify vocabulary object that keeps that representation. + * Nothing here delivers or dereferences anything. + * @module + */ + +export { parseVocab, publicDocument } from "./serving/document.ts"; +export { + type ActivitySnapshot, + loadActivitySnapshot, +} from "./serving/resource.ts"; diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts new file mode 100644 index 0000000..f1f1d82 --- /dev/null +++ b/packages/federation/src/serving/document.ts @@ -0,0 +1,380 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { + ADDRESSING_PROPERTIES, + type DocumentNode, + type ExtensionNode, + type FieldPath, + type JsonValue, + describeDocument, +} from "@drfed/models/document"; +import { PUBLIC_IRI } from "@drfed/models/resource"; +import type { Context } from "@fedify/fedify"; +import { Object as APObject } from "@fedify/vocab"; + +import type { JsonObject } from "../local-write/types.ts"; + +const ACTIVITY_STREAMS = "https://www.w3.org/ns/activitystreams"; +/** The type IRI of `Create`, which has its own activity route. */ +export const CREATE_TYPE = `${ACTIVITY_STREAMS}#Create`; +/** The type IRI of `Delete`, which outlives the object it deletes. */ +export const DELETE_TYPE = `${ACTIVITY_STREAMS}#Delete`; +const BLIND_PROPERTIES = ["bto", "bcc"] as const; +const RECIPIENT_PROPERTIES: ReadonlySet = new Set( + ADDRESSING_PROPERTIES, +); + +const pathKey = (path: FieldPath): string => JSON.stringify(path); + +/** What serving reads of a described document or extension node object. */ +type Described = Pick; + +/** + * Collects the blind addressing keys of each described document and + * extension node object, whatever their spelling, by its source path. + * @returns The keys to omit, by path. + */ +const blindKeys = ( + nodes: readonly Pick[], +): ReadonlyMap> => + new Map( + nodes.map((node) => [ + pathKey(node.path), + new Set( + BLIND_PROPERTIES.flatMap( + (property) => node.properties.get(property) ?? [], + ), + ), + ]), + ); + +const omitKeys = ( + value: JsonValue, + path: FieldPath, + omitted: ReadonlyMap>, +): JsonValue => { + if (Array.isArray(value)) { + return value.map((entry: JsonValue, index) => + omitKeys(entry, [...path, index], omitted), + ); + } + if (value === null || typeof value !== "object") return value; + const keys = omitted.get(pathKey(path)); + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => keys?.has(key) !== true) + .map(([key, entry]) => [key, omitKeys(entry, [...path, key], omitted)]), + ); +}; + +/** + * States the context a document without `@context` is read with, the + * ActivityStreams one, as Activity Streams 2.0 asks of documents it + * produces. + * @returns The document, with `@context` first if it had none. + */ +const statedContext = (document: JsonObject): JsonObject => + "@context" in document + ? document + : { "@context": ACTIVITY_STREAMS, ...document }; + +// An embedded document sits at [...parent, key] or [...parent, key, index]. +const parentKey = (path: FieldPath): string | null => + path.length === 0 + ? null + : pathKey(path.slice(0, typeof path.at(-1) === "number" ? -2 : -1)); + +/** + * Reads a document's own IRI value of a functional property, such as its `id` + * or `href`, written alone or as a singleton list. A node's identifiers are + * its own values only. + * @returns The IRI with a compact IRI expanded, or `null` when there is none. + */ +const ownIdentifier = (node: Described, property: string): string | null => + node.identifiers.find((entry) => entry.property === property)?.iri ?? null; + +const ownId = (node: Described): string | null => ownIdentifier(node, "id"); + +/** A document or node object as the one that embeds it reads it. */ +interface Embedded { + /** The source path of the document or node object that embeds it. */ + readonly parent: string | null; + /** The standard name of the property that embeds it. */ + readonly property?: string | undefined; + /** The IRI the addressing index records for it as a recipient. */ + readonly iri: string | null; + /** The IRI it refers to as a `Link`, by its `href`. */ + readonly href: string | null; +} + +const embeddedDocument = (node: DocumentNode): Embedded => { + const href = node.kind === "link" ? ownIdentifier(node, "href") : null; + return { + parent: parentKey(node.path), + property: node.property, + iri: node.kind === "link" ? href : ownId(node), + href, + }; +}; + +// A node object in an extension value is read as a recipient by its href +// when one of its types is a Link, and by its id otherwise. Its href is a +// reference whatever its type, and its id is embedded. +const embeddedExtension = (node: ExtensionNode): Embedded => { + const href = ownIdentifier(node, "href"); + return { + parent: node.parent == null ? null : pathKey(node.parent), + property: node.property, + iri: node.kind === "link" ? href : ownId(node), + href, + }; +}; + +/** + * Reads a document's own recipients as the addressing index records those of + * a saved document: references as resolved, embedded documents by `id`, and + * `Link` values by `href`. An anonymous embedded recipient names no IRI. + * @param children The documents or node objects it embeds. + * @returns The addressing property and IRI of each recipient. + */ +const recipients = ( + node: Described, + children: readonly Embedded[], +): { readonly property: string; readonly iri: string | null }[] => [ + ...node.identifiers + .filter(({ property }) => RECIPIENT_PROPERTIES.has(property)) + .map(({ property, iri }) => ({ property, iri })), + ...children + .filter( + (child) => + child.property != null && RECIPIENT_PROPERTIES.has(child.property), + ) + .map((child) => ({ property: child.property!, iri: child.iri })), +]; + +/** + * Tells whether a document's own addressing names recipients but not the + * public in `to` or `cc`, which restricts its content to them. A document + * without recipients of its own goes with the document that embeds it. + * @param children The documents or node objects it embeds. + * @returns Whether the document is restricted. + */ +const isRestricted = ( + node: Described, + children: readonly Embedded[], +): boolean => { + const named = recipients(node, children); + return ( + named.length > 0 && + !named.some( + ({ property, iri }) => + (property === "to" || property === "cc") && iri === PUBLIC_IRI, + ) + ); +}; + +/** + * Reads the IRIs an activity's `object` refers to without embedding them, as + * references or by the `href` of `Link` values, including that of a `Link` + * the document embeds elsewhere with the `id` a reference names. + * @param children The documents or node objects it embeds. + * @param links The `href` of each `Link` in the document, by its `id`. + * @returns The IRIs. + */ +const referredObjects = ( + node: Described, + children: readonly Embedded[], + links: ReadonlyMap, +): string[] => [ + ...node.identifiers + .filter(({ property }) => property === "object") + .flatMap(({ iri }) => [iri, ...(links.get(iri) ?? [])]), + ...children + .filter((child) => child.property === "object") + .map(({ href }) => href) + .filter((iri) => iri != null), +]; + +/** A saved document as it is served, and what its validator reading says. */ +export interface ServedDocument { + /** The type IRI, whatever key or term spells it. */ + readonly type: string; + /** The `id` with a compact IRI expanded, whatever key spells it. */ + readonly id: string | null; + /** + * The document without blind recipients, stating the ActivityStreams + * context if it had no `@context`. + */ + readonly document: JsonObject; +} + +/** What serving a saved document depends on besides the document itself. */ +export interface DocumentInspection { + readonly served: ServedDocument; + /** + * The `id` of every embedded document and extension node object, at any + * depth. + */ + readonly embedded: readonly string[]; + /** + * The IRIs that the `object` of the root, of an embedded activity or of an + * extension node object, unless it is a `Delete`, refers to without + * embedding them, as references or `Link` values. + */ + readonly objectReferences: readonly string[]; + /** + * Whether an embedded document or extension node object, at any depth, is + * addressed to recipients but not to the public, so that serving the root + * publicly would expose it to others. + */ + readonly restricted: boolean; + /** + * Whether the validator cannot read all of an extension node object, as + * when it has a `@context` of its own, or two node objects share an `id`, + * so that the recipients and references of what they describe cannot be + * told from any one of them. + */ + readonly uninterpreted: boolean; +} + +/** + * Reads a saved document once for serving. + * @param document A saved document, with its blind recipients. + * @returns The inspection, or `null` when the validator cannot read the + * document, which is then not served. + */ +export function inspectDocument( + document: JsonObject, +): DocumentInspection | null { + const nodes = describeDocument(document); + const root = nodes?.[0]; + if (nodes == null || root == null) return null; + const embedded = nodes.filter( + ({ path, kind }) => path.length > 0 && kind !== "link", + ); + const extensions = nodes.flatMap((node) => node.extensions); + const byParent = Map.groupBy( + [...nodes.map(embeddedDocument), ...extensions.map(embeddedExtension)], + ({ parent }) => parent, + ); + const children = (node: Described): readonly Embedded[] => + byParent.get(pathKey(node.path)) ?? []; + const links = new Map( + [ + ...Map.groupBy( + [...nodes, ...extensions].flatMap((node) => { + const id = ownId(node); + const href = ownIdentifier(node, "href"); + return node.kind === "link" && id != null && href != null + ? [{ id, href }] + : []; + }), + ({ id }) => id, + ), + ].map(([id, entries]) => [id, entries.map(({ href }) => href)]), + ); + // Node objects that share an id are one node, which no single one tells. + const ids = [...nodes, ...extensions].map(ownId).filter((iri) => iri != null); + return { + served: { + type: root.type, + id: ownId(root), + document: statedContext( + omitKeys( + document, + [], + blindKeys([...nodes, ...extensions]), + ) as JsonObject, + ), + }, + embedded: [...embedded, ...extensions] + .map(ownId) + .filter((iri) => iri != null), + // A node object in an extension value has no kind the validator reads. + objectReferences: [ + ...nodes.filter( + ({ kind, type }) => kind === "activity" && type !== DELETE_TYPE, + ), + ...extensions.filter(({ types }) => !types.includes(DELETE_TYPE)), + ].flatMap((node) => referredObjects(node, children(node), links)), + restricted: [...embedded, ...extensions].some((node) => + isRestricted(node, children(node)), + ), + uninterpreted: + extensions.some(({ uninterpreted }) => uninterpreted) || + new Set(ids).size < ids.length, + }; +} + +/** + * Reads a saved document once for serving. See {@link publicDocument}. + * @param document A saved document, with its blind recipients. + * @returns The served form, or `null` when the validator cannot read the + * document or its blind recipients cannot be found, so that it is + * not served. + */ +export function serveDocument(document: JsonObject): ServedDocument | null { + const inspection = inspectDocument(document); + return inspection == null || inspection.uninterpreted + ? null + : inspection.served; +} + +/** + * Removes blind recipients from a saved document for public or federated + * output: the `bto` and `bcc` of the root, of every embedded document and of + * every JSON-LD node object in extension values, read with the context it + * inherits, found by meaning, so that aliases, full-IRI keys and compact IRI + * keys are removed as well. JSON literals stay as they are. A document + * without `@context` states the ActivityStreams context the validator read + * it with. Everything else, including the context, extension properties, + * references, embedded values and the order of keys and values, stays as it + * was saved. The input is not changed. + * @param document A saved document, with its blind recipients. + * @returns A new document without them, or `null` when the validator cannot + * read the document offline, or when a node object in an extension + * value has a `@context` of its own, which the validator does not + * read; such a document is not served. + */ +export function publicDocument(document: JsonObject): JsonObject | null { + return serveDocument(document)?.document ?? null; +} + +/** + * Parses a document into a Fedify vocabulary object with the context's + * loaders. Fedify keeps the parsed JSON-LD and returns it as the object's + * representation, so the object keeps the document's context and extension + * properties. Fedify 2.4.3 cannot parse every document the validator + * accepts, such as one with a `Link` in `audience` or `object`; federation + * reads serve those without parsing them. Nothing is dereferenced. An object derived from + * the result, as by `clone()`, loses that representation and is serialized + * from the properties Fedify knows. A document without `@context` is + * parsed, and represented, with the ActivityStreams context. + * @param ctx The Fedify context whose loaders resolve JSON-LD contexts. + * @param document The document to parse, usually from {@link publicDocument}. + * @returns The vocabulary object. + * @throws {TypeError} If Fedify cannot parse the document. + */ +export async function parseVocab( + ctx: Pick, "contextLoader" | "documentLoader">, + document: JsonObject, +): Promise { + return await APObject.fromJsonLd(statedContext(document), { + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + }); +} diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts new file mode 100644 index 0000000..ba8cffa --- /dev/null +++ b/packages/federation/src/serving/resource.ts @@ -0,0 +1,574 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { type Database, type Transaction, schema } from "@drfed/models"; +import { loadCurrentResourceRevision } from "@drfed/models/resource-revision"; +import type { Uuid } from "@drfed/models/uuid"; +import type { Context } from "@fedify/fedify"; +import { Object as APObject, Activity, Tombstone } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { type SQL, and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; + +import { findActiveActor } from "../actor.ts"; +import type { JsonObject } from "../local-write/types.ts"; +import { + activitySelection, + objectSelection, + toCreate, + toObject, +} from "../object.ts"; +import { canonicalizeAuthority } from "../origin.ts"; +import { + deletedLocalObject, + inLocalOutbox, + listedItem, + publicAddressing, + servedActivity, + servedObjectRevision, +} from "../visibility.ts"; +import { + CREATE_TYPE, + type DocumentInspection, + type ServedDocument, + inspectDocument, +} from "./document.ts"; +import { documentActivity, documentObject } from "./vocab.ts"; + +const logger = getLogger(["drfed", "federation", "serving"]); + +const { + activities, + collectionItems, + resourceHeads, + resourceRevisions, + resources, +} = schema; + +/** + * The activity routes: `create` for `/ap/creates/{id}`, which serves `Create` + * activities, and `activity` for `/ap/activities/{id}`, which serves the + * others. + */ +export type ActivityRoute = "create" | "activity"; + +/** The current revision of a saved resource, as the served queries read it. */ +interface RevisionRow { + readonly iri: string; + readonly revisionId: string | null; + readonly document: unknown; +} + +// Lookups of deleted objects stay well below the 65,535 parameters +// PostgreSQL binds in one statement. +const LOOKUP_SIZE = 1000; +// Outbox pages read past hidden activities in batches that grow up to this. +const MAXIMUM_BATCH_SIZE = 1000; +// The outbox counter reads the documents of this many activities at a time. +const COUNT_BATCH_SIZE = 100; +// The most revisions remembered as logged unservable. +const LOGGED_REVISION_LIMIT = 4096; + +/** + * Splits values into chunks of at most a size. + * @returns The chunks, in order. + */ +const chunks = (values: readonly T[], size: number): T[][] => + Array.from({ length: Math.ceil(values.length / size) }, (_, index) => + values.slice(index * size, (index + 1) * size), + ); + +// The revisions logged as unservable, so that one is logged once however +// often pages and counters read it. Past LOGGED_REVISION_LIMIT, the +// revision logged longest ago is forgotten. +const loggedRevisions = new Set(); + +function logOnce(row: RevisionRow, log: () => void): void { + const key = row.revisionId ?? row.iri; + if (loggedRevisions.has(key)) return; + if (loggedRevisions.size >= LOGGED_REVISION_LIMIT) { + loggedRevisions.delete(loggedRevisions.values().next().value!); + } + loggedRevisions.add(key); + log(); +} + +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Reads a saved revision's document for serving. A document the write path + * does not save, one the validator cannot read or one whose `id` is not the + * resource's, can only come from a change made outside it. It is logged and + * left out, so that it takes down nothing but its own resource. A document + * whose node objects cannot be read apart, as in extension values, is logged + * as a warning; it is inspected, and {@link canServe} refuses it. Each + * revision is logged once. + * @returns The inspection, also of a document the rules of + * {@link canServe} refuse, or `null` when the document is not a + * JSON object the validator reads, or has no `id` or one other than + * the resource's IRI. + */ +function inspectRevision(row: RevisionRow): DocumentInspection | null { + const inspection = isJsonObject(row.document) + ? inspectDocument(row.document) + : null; + if (inspection != null && inspection.served.id === row.iri) { + if (inspection.uninterpreted) { + logOnce(row, () => + logger.warn( + "The saved document of {iri} has node objects whose addressing " + + "or references cannot be read; it is not served.", + { iri: row.iri }, + ), + ); + } + return inspection; + } + logOnce(row, () => + logger.error( + "The saved document of {iri} cannot be served; leaving it out.", + { iri: row.iri }, + ), + ); + return null; +} + +/** + * Applies the rules only a saved document can tell. Content embedded at any + * depth, including a node object in an extension value, is not served + * publicly when it is addressed to others but not to the public, nor when it + * is a deleted local object. An activity at any depth whose `object` refers + * to a deleted local object hides the document unless it is a `Delete`. A + * document whose blind recipients cannot be found is not served. + * @param deleted The deleted local objects among what the document embeds + * and refers to. + * @returns Whether these rules let the document be served. Callers apply + * the rest: the actor and resource rules where they select the + * rows, and the route of a saved activity on the served document's + * type. + */ +function canServe( + inspection: DocumentInspection, + deleted: ReadonlySet, +): boolean { + return ( + !inspection.restricted && + !inspection.uninterpreted && + ![...inspection.embedded, ...inspection.objectReferences].some((iri) => + deleted.has(iri), + ) + ); +} + +/** + * Finds the deleted local objects among IRIs, in lookups of a bounded size. + * @returns The IRIs of those objects. + */ +async function findDeletedObjects( + db: Database, + iris: readonly string[], +): Promise> { + const rows = await Promise.all( + chunks(iris, LOOKUP_SIZE).map((chunk) => + db + .select({ iri: resources.iri }) + .from(resources) + .where( + and(inArray(resources.iri, chunk), deletedLocalObject(resources.id)), + ), + ), + ); + return new Set(rows.flat().map(({ iri }) => iri)); +} + +/** + * Reads saved revisions for serving, all against one lookup of the deleted + * objects they embed or refer to. + * @returns For each revision, its served form, or `null` when it is not + * served. + */ +async function serveRevisions( + db: Database, + rows: readonly RevisionRow[], +): Promise<(ServedDocument | null)[]> { + const inspections = rows.map(inspectRevision); + const deleted = await findDeletedObjects(db, [ + ...new Set( + inspections.flatMap((inspection) => + inspection == null + ? [] + : [...inspection.embedded, ...inspection.objectReferences], + ), + ), + ]); + return inspections.map((inspection) => + inspection != null && canServe(inspection, deleted) + ? inspection.served + : null, + ); +} + +/** + * Finds the activity a route serves. A legacy `Create` is serialized from + * its typed row; a saved activity is its current document without blind + * recipients, served at the route its type was given. + * @param ctx The context of the request, whose host must be the instance of + * the actor whose outbox holds the activity. + * @returns The activity, or `null` when the route does not serve it. + */ +export async function findServedActivity( + db: Database, + ctx: Context, + id: Uuid, + route: ActivityRoute, +): Promise { + const [row] = await db + .select({ + iri: resources.iri, + typed: sql`${activities.id} is not null`, + revisionId: resourceRevisions.id, + document: resourceRevisions.document, + }) + .from(resources) + .leftJoin(activities, eq(activities.id, resources.id)) + .leftJoin(resourceHeads, eq(resourceHeads.resourceId, resources.id)) + .leftJoin( + resourceRevisions, + eq(resourceRevisions.id, resourceHeads.revisionId), + ) + .where( + and( + eq(resources.id, id), + inLocalOutbox(resources.id, canonicalizeAuthority(ctx.host)), + servedActivity(resources.id), + ), + ) + .limit(1); + if (row == null) return null; + if (row.typed) { + if (route !== "create") return null; + const activity = await db.query.activities.findFirst({ + where: { id }, + with: activitySelection, + }); + return activity == null ? null : toCreate(ctx, activity); + } + const [served] = await serveRevisions(db, [row]); + if ( + served == null || + (served.type === CREATE_TYPE) !== (route === "create") + ) { + return null; + } + return documentActivity(served); +} + +/** + * Finds the object the actor's object route serves. A legacy object is + * serialized from its typed row; a saved one is its current document without + * blind recipients. A deleted object is a `Tombstone` with only its `id` + * and deletion time. + * @param ctx The context of the request, whose host must be the actor's + * instance. + * @returns The object, or `null` when the route does not serve it. + */ +export async function findServedObject( + db: Database, + ctx: Context, + actorId: Uuid, + id: Uuid, +): Promise { + const typed = await db.query.objects.findFirst({ + where: { + id, + actorId, + RAW: (table) => publicAddressing(table.id), + actor: { + localId: { isNotNull: true }, + deleted: { isNull: true }, + instance: { host: canonicalizeAuthority(ctx.host) }, + }, + }, + with: objectSelection, + }); + if (typed != null) { + return typed.deleted == null + ? toObject(ctx, typed) + : new Tombstone({ + id: new URL(typed.resource.iri), + deleted: typed.deleted, + }); + } + const actor = await findActiveActor(db, ctx, actorId); + if (actor == null) return null; + const [row] = await db + .select({ + iri: resources.iri, + kind: resourceRevisions.kind, + revisionId: resourceRevisions.id, + document: resourceRevisions.document, + deleted: resourceRevisions.deleted, + }) + .from(resources) + .innerJoin(resourceHeads, eq(resourceHeads.resourceId, resources.id)) + .innerJoin( + resourceRevisions, + eq(resourceRevisions.id, resourceHeads.revisionId), + ) + .where( + and( + eq(resources.id, id), + eq(resources.kind, "object"), + servedObjectRevision(resources.id, actor.resource.iri), + ), + ) + .limit(1); + if (row == null) return null; + if (row.kind === "whiteout") { + return new Tombstone({ id: new URL(row.iri), deleted: row.deleted }); + } + if (row.kind !== "snapshot") return null; + const [served] = await serveRevisions(db, [row]); + return served == null ? null : documentObject(served); +} + +/** One page of an outbox. */ +export interface OutboxPage { + readonly items: readonly Activity[]; + /** The position after which the next page starts, if there is one. */ + readonly nextCursor: number | null; +} + +const outboxItems = (outboxId: Uuid): SQL => + and( + eq(collectionItems.collectionId, outboxId), + listedItem(collectionItems.position), + servedActivity(collectionItems.itemId), + )!; + +/** + * Loads a batch of outbox items after a position, in the order of their + * positions. + * @returns Each item's position, and its activity or `null` when it is not + * served. + */ +async function loadOutboxBatch( + db: Database, + ctx: Context, + outboxId: Uuid, + after: number | null, + size: number, +): Promise< + { readonly position: number; readonly activity: Activity | null }[] +> { + const rows = await db + .select({ + id: collectionItems.itemId, + position: collectionItems.position, + iri: resources.iri, + typed: sql`${activities.id} is not null`, + revisionId: resourceRevisions.id, + document: resourceRevisions.document, + }) + .from(collectionItems) + .innerJoin(resources, eq(resources.id, collectionItems.itemId)) + .leftJoin(activities, eq(activities.id, collectionItems.itemId)) + .leftJoin( + resourceHeads, + eq(resourceHeads.resourceId, collectionItems.itemId), + ) + .leftJoin( + resourceRevisions, + eq(resourceRevisions.id, resourceHeads.revisionId), + ) + .where( + and( + outboxItems(outboxId), + after == null ? undefined : gt(collectionItems.position, after), + ), + ) + .orderBy(asc(collectionItems.position)) + .limit(size); + const typedIds = rows.filter(({ typed }) => typed).map(({ id }) => id); + const typed = + typedIds.length === 0 + ? [] + : await db.query.activities.findMany({ + where: { id: { in: typedIds } }, + with: activitySelection, + }); + const byId = new Map(typed.map((activity) => [activity.id, activity])); + const saved = rows.filter((row) => !row.typed); + const served = new Map( + (await serveRevisions(db, saved)).map((document, index) => [ + saved[index]!.id, + document, + ]), + ); + return rows.map((row) => { + // Listed items have positions; see listedItem(). + const position = row.position!; + if (row.typed) { + const activity = byId.get(row.id); + return { + position, + activity: activity == null ? null : toCreate(ctx, activity), + }; + } + const document = served.get(row.id); + return { + position, + activity: document == null ? null : documentActivity(document), + }; + }); +} + +/** + * Loads one page of the served activities in an outbox, in the order of + * their positions, where the newest has the smallest. Legacy `Create` + * activities and saved activities are mixed in that one order. Items their + * documents leave out are read past, in batches that double up to a bound, + * until the page is full, and a next page is promised only when a served + * activity follows, so that no page but the last is short and none is empty + * with a next page. + * @param after The position the page starts after; `null` for the first. + * @returns The page. + */ +export async function loadOutboxPage( + db: Database, + ctx: Context, + outboxId: Uuid, + after: number | null, + limit: number, +): Promise { + const items: { readonly position: number; readonly activity: Activity }[] = + []; + let cursor = after; + let size = limit + 1; + // One served activity past the page tells whether a next page exists. + while (items.length <= limit) { + // Each batch starts where the previous one ended. + // oxlint-disable-next-line no-await-in-loop + const batch = await loadOutboxBatch(db, ctx, outboxId, cursor, size); + for (const { position, activity } of batch) { + if (activity != null) items.push({ position, activity }); + } + if (batch.length < size) break; + cursor = batch.at(-1)!.position; + if (size < MAXIMUM_BATCH_SIZE) { + size = Math.min(size * 2, MAXIMUM_BATCH_SIZE); + } + } + const page = items.slice(0, limit); + return { + items: page.map(({ activity }) => activity), + nextCursor: items.length > limit ? page.at(-1)!.position : null, + }; +} + +/** + * Counts the served activities in an outbox, by the same rules as + * {@link loadOutboxPage}. The documents of its saved activities are read in + * batches of a bounded size, since only they tell what those activities + * embed and refer to. + * @returns The number of activities its pages hold together. + */ +export async function countOutbox( + db: Database, + outboxId: Uuid, +): Promise { + const [legacy] = await db + .select({ count: sql`cast(count(*) as integer)` }) + .from(collectionItems) + .innerJoin(activities, eq(activities.id, collectionItems.itemId)) + .where(outboxItems(outboxId)); + let count = legacy?.count ?? 0; + let after: Uuid | null = null; + for (;;) { + // Each batch starts after the item the previous one ended with. + // oxlint-disable-next-line no-await-in-loop + const saved = await db + .select({ + id: collectionItems.itemId, + iri: resources.iri, + revisionId: resourceRevisions.id, + document: resourceRevisions.document, + }) + .from(collectionItems) + .innerJoin(resources, eq(resources.id, collectionItems.itemId)) + .innerJoin( + resourceHeads, + eq(resourceHeads.resourceId, collectionItems.itemId), + ) + .innerJoin( + resourceRevisions, + eq(resourceRevisions.id, resourceHeads.revisionId), + ) + .leftJoin(activities, eq(activities.id, collectionItems.itemId)) + .where( + and( + outboxItems(outboxId), + isNull(activities.id), + after == null ? undefined : gt(collectionItems.itemId, after), + ), + ) + .orderBy(asc(collectionItems.itemId)) + .limit(COUNT_BATCH_SIZE); + // oxlint-disable-next-line no-await-in-loop + const served = await serveRevisions(db, saved); + count += served.filter((document) => document != null).length; + if (saved.length < COUNT_BATCH_SIZE) return count; + after = saved.at(-1)!.id; + } +} + +/** + * A saved activity's current revision, for outgoing delivery to read. + * Unlike what federation reads serve, its document keeps its blind + * recipients, so that a sender can deliver to them and remove them from what + * it sends with `publicDocument()`. Nothing dispatches it yet; sending test + * activities and retrying failed deliveries + * () will. + */ +export interface ActivitySnapshot { + /** The revision the document belongs to, for a sender to record. */ + readonly revisionId: Uuid; + /** The protected document, including blind recipients. */ + readonly document: JsonObject; +} + +/** + * Loads a saved activity's current document, whether it is published or + * not, without applying any serving rule; callers authorize its use and + * remove blind recipients from public output with `publicDocument()`. + * @returns The snapshot, or `null` when the resource is not a saved + * activity. + */ +export async function loadActivitySnapshot( + db: Database | Transaction, + id: Uuid, +): Promise { + const resource = await db.query.resources.findFirst({ + where: { id }, + columns: { kind: true }, + }); + if (resource?.kind !== "activity") return null; + const head = await loadCurrentResourceRevision(db, id); + return head?.kind === "snapshot" && isJsonObject(head.document) + ? { revisionId: head.id, document: head.document } + : null; +} diff --git a/packages/federation/src/serving/vocab.ts b/packages/federation/src/serving/vocab.ts new file mode 100644 index 0000000..68c0692 --- /dev/null +++ b/packages/federation/src/serving/vocab.ts @@ -0,0 +1,114 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-classes-per-file -- One subclass per vocabulary class Fedify and the dispatchers check with instanceof. + +import { Object as APObject, Activity, Create } from "@fedify/vocab"; + +import type { JsonObject } from "../local-write/types.ts"; +import { CREATE_TYPE, type ServedDocument } from "./document.ts"; + +/** The options of Fedify's `toJsonLd()`. */ +type JsonLdOptions = NonNullable[0]>; + +/** + * Serializes a stored document as a vocabulary object's JSON-LD. Fedify + * serves objects and collection items in the default form, which is the + * document itself; an explicit form is Fedify's own reading of the + * document, which fails for values Fedify cannot read. + * @returns The JSON-LD. + */ +async function documentJsonLd( + document: JsonObject, + options: JsonLdOptions, +): Promise { + if (options.format == null) return structuredClone(document); + const parsed = await APObject.fromJsonLd( + document, + options.contextLoader == null + ? {} + : { contextLoader: options.contextLoader }, + ); + return await parsed.toJsonLd(options); +} + +class DocumentObject extends APObject { + readonly #document: JsonObject; + + constructor(id: URL, document: JsonObject) { + super({ id }); + this.#document = document; + } + + override async toJsonLd(options: JsonLdOptions = {}): Promise { + return await documentJsonLd(this.#document, options); + } +} + +class DocumentActivity extends Activity { + readonly #document: JsonObject; + + constructor(id: URL, document: JsonObject) { + super({ id }); + this.#document = document; + } + + override async toJsonLd(options: JsonLdOptions = {}): Promise { + return await documentJsonLd(this.#document, options); + } +} + +class DocumentCreate extends Create { + readonly #document: JsonObject; + + constructor(id: URL, document: JsonObject) { + super({ id }); + this.#document = document; + } + + override async toJsonLd(options: JsonLdOptions = {}): Promise { + return await documentJsonLd(this.#document, options); + } +} + +function servedId(served: ServedDocument): URL { + if (served.id == null) throw new TypeError("A served document needs an id."); + return new URL(served.id); +} + +/** + * Wraps a served activity document in a vocabulary object that Fedify serves + * as the document itself, without parsing it, so that a document with values + * Fedify cannot read, such as a `Link` in `audience` or `object`, is served + * as saved. Its only property is its `id`; it is a `Create` for a `Create`. + * @param served A served document, as `serveDocument()` makes it. + * @returns The vocabulary object. + */ +export function documentActivity(served: ServedDocument): Activity { + return served.type === CREATE_TYPE + ? new DocumentCreate(servedId(served), served.document) + : new DocumentActivity(servedId(served), served.document); +} + +/** + * Wraps a served object document in a vocabulary object that Fedify serves + * as the document itself. See {@link documentActivity}. + * @param served A served document, as `serveDocument()` makes it. + * @returns The vocabulary object. + */ +export function documentObject(served: ServedDocument): APObject { + return new DocumentObject(servedId(served), served.document); +} diff --git a/packages/federation/src/visibility.ts b/packages/federation/src/visibility.ts index 1e9945c..ca951af 100644 --- a/packages/federation/src/visibility.ts +++ b/packages/federation/src/visibility.ts @@ -17,6 +17,20 @@ import { schema } from "@drfed/models"; import { PUBLIC_RESOURCE_ID } from "@drfed/models/resource"; import { type SQL, type SQLWrapper, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; + +const { + activities, + actorCollectionReferences, + actors, + collectionItems, + instances, + objects, + resourceHeads, + resourceRevisions, + resources, + revisionRelations, +} = schema; /** * Shared Public predicate for object, activity, outbox page and counter. @@ -26,17 +40,135 @@ export function publicAddressing(sourceId: SQLWrapper): SQL { return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`; } +// The predicates below are nested into queries on the same tables, so each +// subquery reads an alias of its own to never capture the outer row. An +// alias renders as its name alone, so a FROM clause names its table as well. +const typedActivities = alias(activities, "served_typed_activities"); +const typedObjects = alias(objects, "served_typed_objects"); +const heads = alias(resourceHeads, "served_heads"); +const revisions = alias(resourceRevisions, "served_revisions"); +const relations = alias(revisionRelations, "served_relations"); +const servedResources = alias(resources, "served_resources"); +const items = alias(collectionItems, "served_items"); +const references = alias(actorCollectionReferences, "served_references"); +const owners = alias(actors, "served_owners"); +const ownerInstances = alias(instances, "served_owner_instances"); + +const typedActivity = (id: SQLWrapper): SQL => + sql`exists (select 1 from ${activities} ${typedActivities} where ${typedActivities.id} = ${id})`; + +const typedObject = (id: SQLWrapper): SQL => + sql`exists (select 1 from ${objects} ${typedObjects} where ${typedObjects.id} = ${id})`; + +// A legacy activity is served as `Create` while its object is not deleted. +const servedTypedActivity = (id: SQLWrapper): SQL => sql`exists ( + select 1 from ${activities} ${typedActivities} + where ${typedActivities.id} = ${id} + and ${typedActivities.type} = 'Create' + and exists (select 1 from ${objects} ${typedObjects} where ${typedObjects.id} = ${typedActivities.objectId} and ${typedObjects.deleted} is null) +)`; + +const resourceKind = (id: SQLWrapper, kind: string): SQL => + sql`exists (select 1 from ${resources} ${servedResources} where ${servedResources.id} = ${id} and ${servedResources.kind} = ${kind})`; + +const headKind = (id: SQLWrapper, kind: string): SQL => sql`exists ( + select 1 from ${resourceHeads} ${heads} + join ${resourceRevisions} ${revisions} on ${revisions.id} = ${heads.revisionId} + where ${heads.resourceId} = ${id} and ${revisions.kind} = ${kind} +)`; + +/** + * Matches the activities that are served over ActivityPub, wherever they + * are read: Public activity resources that are either a legacy `Create` + * whose object has not been deleted, or a saved activity whose current + * revision is a snapshot. The activity dispatchers, the outbox pages and + * the outbox counter share it, so that none of them can disagree with the + * others about what is served. A saved activity is served only when what + * its document embeds and refers to allows it as well, which only the + * document can tell; see *serving/resource.ts*. + * @param id The activity's resource ID in an outer query. + * @returns The condition on the ID. + */ +export function servedActivity(id: SQLWrapper): SQL { + return sql`(${resourceKind(id, "activity")} and ${publicAddressing(id)} and (${servedTypedActivity(id)} or (not ${typedActivity(id)} and ${headKind(id, "snapshot")})))`; +} + +/** + * Matches the local objects that have been deleted: those whose legacy row + * of a local actor is, and those whose current revision is a whiteout of a + * locally captured history. + * @param id The object's resource ID in an outer query. + * @returns The condition on the ID. + */ +export function deletedLocalObject(id: SQLWrapper): SQL { + return sql`(${resourceKind(id, "object")} and ( + exists ( + select 1 from ${objects} ${typedObjects} + join ${actors} ${owners} on ${owners.id} = ${typedObjects.actorId} + where ${typedObjects.id} = ${id} + and ${typedObjects.deleted} is not null + and ${owners.localId} is not null + ) + or exists ( + select 1 from ${resourceHeads} ${heads} + join ${resourceRevisions} ${revisions} on ${revisions.id} = ${heads.revisionId} + where ${heads.resourceId} = ${id} + and ${revisions.kind} = 'whiteout' + and ${revisions.capture} = 'local' + ) + ))`; +} + +/** + * Matches the outbox items that pages list: those with a position, since a + * page cursor never reaches an item without one. The activity dispatchers + * and the outbox pages and counter share it. + * @param position The item's position in an outer query. + * @returns The condition on the position. + */ +export function listedItem(position: SQLWrapper): SQL { + return sql`${position} is not null`; +} + +/** + * Matches the resources listed, as {@link listedItem} tells, in the outbox of + * a live local actor of the instance the request names. Another instance's + * authority cannot reach a resource by guessing its identifier. + * @param id The resource ID in an outer query. + * @param host The canonical authority of the request. + * @returns The condition on the ID. + */ +export function inLocalOutbox(id: SQLWrapper, host: string): SQL { + return sql`exists ( + select 1 from ${collectionItems} ${items} + join ${actorCollectionReferences} ${references} on ${references.collectionId} = ${items.collectionId} and ${references.role} = 'outbox' + join ${actors} ${owners} on ${owners.id} = ${references.actorId} + join ${instances} ${ownerInstances} on ${ownerInstances.id} = ${owners.instanceId} + where ${items.itemId} = ${id} + and ${listedItem(items.position)} + and ${owners.localId} is not null + and ${owners.deleted} is null + and ${ownerInstances.host} = ${host} + )`; +} + /** - * Matches the activities that are served over ActivityPub: Public `Create` - * activities whose object has not been deleted. The Create dispatcher, the - * outbox pages and the outbox counter share it, so that none of them can - * disagree with the others about what is served. - * @returns A predicate on the activities table. + * Matches the saved objects, without a legacy row, that are served at the + * actor's object route: Public, and attributed to the actor by their last + * snapshot, which for a deleted object is the version before its whiteout. + * @param id The object's resource ID in an outer query. + * @param actorIri The IRI of the live local actor the route names. + * @returns The condition on the ID. */ -export function servedActivity(table: { - id: SQLWrapper; - objectId: SQLWrapper; - type: SQLWrapper; -}): SQL { - return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`; +export function servedObjectRevision(id: SQLWrapper, actorIri: string): SQL { + return sql`(not ${typedObject(id)} and ${publicAddressing(id)} and exists ( + select 1 from ${revisionRelations} ${relations} + where ${relations.revisionId} = ( + select ${revisions.id} from ${resourceRevisions} ${revisions} + where ${revisions.resourceId} = ${id} and ${revisions.kind} = 'snapshot' + order by ${revisions.sequence} desc limit 1 + ) + and ${relations.property} = 'attributedTo' + and ${relations.targetIri} = ${actorIri} + ))`; } diff --git a/packages/models/README.md b/packages/models/README.md index 03b6199..cab9ed5 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -12,7 +12,7 @@ Document validation `@drfed/models/document` exports synchronous `validateDocument(document: unknown)`, `prepareDocument(document, options)`, -and `describeDocument(document: unknown)`, plus the `diagnosticReasons` +and `describeDocument(document: unknown)`, plus the `DIAGNOSTIC_REASONS` registry. Public types are `DocumentValidationResult`, `Diagnostic`, `DiagnosticReason`, `DiagnosticCategory`, `DiagnosticSeverity`, `FieldPath`, `JsonValue`, `NormalizationChange`, `DocumentAuthority`, `ServerOwnedField`, @@ -80,12 +80,39 @@ its type IRI, a `kind` (`activity`, `actor`, `collection`, `link`, or `object`), and a map from standard property names, including `id` and `type`, to the source keys that supply them. Aliases, prefixed or full-IRI keys, and `@id`/`@type` aliases are mapped exactly as validation maps them; custom -extension values are neither listed nor descended into. Each node also lists -the string IRI references in its own reference properties (`id`, `actor`, -`object`, addressing, `href`, and so on) as `DocumentIdentifier` values, with -the original path and value and the IRI after compact-IRI prefix expansion: -`as:Public` resolves to the full Public IRI, and a context-defined prefix can -make `local:alice` name another host. +extension keys are not listed. Each node also lists the string IRI references +in its own reference properties (`id`, `actor`, `object`, addressing, `href`, +and so on) as `DocumentIdentifier` values, with the original path and value +and the IRI after compact-IRI prefix expansion: `as:Public` resolves to the +full Public IRI, and a context-defined prefix can make `local:alice` name +another host. + +Custom extension values are not read as documents, but a JSON-LD processor +reads the node objects in them with the context they inherit. Each node lists +those in its own extension values, outside JSON literals, as its `extensions`: +`ExtensionNode` values with a path, an embedding property, a property map and +IRI references as a node has, and the `parent` node object, `types` and the +`kind` of a known type too. Keys are matched by IRI whatever their container, +and values are read through `@set` and `@list` objects. A node object with a +`@context` of its own, or inside one, is `uninterpreted`, with no properties, +since the checker does not read that context. So is one whose reference +property holds anything but absolute IRIs, a blank node identifier as its +`id`, and node objects: a value object, a language map, a string its key does +not coerce to an IRI, a relative reference, a blank node identifier, which may +name a node object defined apart, or a `@set` or `@list` object with a +`@context` of its own; and one with more than one `id` or `href`, or a node +object as one, or with properties nested by `@nest` or reversed by `@reverse`, +or one whose `id` is used elsewhere in the document, as the `id` of another +node object or a reference outside its own properties, since it is then not +read apart; a `Link` whose `href` is its own `id` is read. A value or a list +object with keys other than keywords, or a value that is an object but no JSON +literal, which are no valid JSON-LD, or one with keys a context of its own may +alias, is reported as an uninterpreted node object. The value of a value +object, or of a key with a `@language` container that is a language map, is +never read for node objects; an object in a language map is no valid JSON-LD, +and is reported as an uninterpreted node object. A blank node identifier is +never expanded as a compact IRI, whatever term `_` names, so a type spelled as +one is unsupported. ~~~~ ts import { describeDocument } from "@drfed/models/document"; @@ -311,9 +338,10 @@ values must be strings or arrays of strings; malformed entries receive `invalid_value_shape` at the original keyword or list-entry path. These shape checks do not require custom type strings to appear in the implemented-type set. An `@json` value's payload remains ordinary JSON, including keys spelled -`@id` or `@type`, and is checked only for JSON safety. These bounded checks do -not establish full JSON-LD validity or interpret nested contexts and keyword -aliases. +`@id` or `@type`, and is checked only for JSON safety; so is the payload of a +value object whose `@type` is spelled with a keyword alias, such as `type`. +These bounded checks do not establish full JSON-LD validity or interpret +nested contexts. This warning policy does not admit unknown types, unsupported contexts, JSON-LD keywords, or known vocabulary properties without implemented rules. diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index eb9ae7e..2843406 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -21,11 +21,11 @@ import { describe, it } from "node:test"; import { promisify } from "node:util"; import { + DIAGNOSTIC_REASONS, type DiagnosticReason, type DocumentPreparationOptions, type FieldPath, describeDocument, - diagnosticReasons, prepareDocument, validateDocument, } from "@drfed/models/document"; @@ -67,13 +67,21 @@ const { fixtures } = JSON.parse( ), ) as { fixtures: readonly DraftFixture[] }; -const context = "https://www.w3.org/ns/activitystreams"; -const like = { - "@context": context, +const CONTEXT = "https://www.w3.org/ns/activitystreams"; +const LIKE = { + "@context": CONTEXT, type: "Like", actor: "https://example.com/alice", object: "https://example.com/posts/42", }; +const CREATION_OPTIONS: DocumentPreparationOptions = { + authority: [{ allowed: true, path: [] }], + serverOwnedFields: [ + { path: ["id"], value: "https://example.com/objects/new" }, + { path: ["attributedTo"], value: LIKE.actor }, + { path: ["published"], value: "2026-10-27T12:00:00Z" }, + ], +}; describe("validateDocument() supported document checks", () => { it("initializes bundled contexts lazily and caches only a successful initialization", async () => { @@ -119,7 +127,7 @@ describe("validateDocument() supported document checks", () => { assert.equal(getEntityTypeById(Type.typeId.href), undefined); for (const type of [Type.name, Type.typeId.href]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type, href: "https://example.com/", }); @@ -134,7 +142,7 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const input = Object.freeze({ - "@context": context, + "@context": CONTEXT, type: "Link", href: "https://example.com/", rel: Object.freeze(["alternate", "alternate"]), @@ -157,7 +165,7 @@ describe("validateDocument() supported document checks", () => { href: "https://example.com/", }; link.preview = link; - const result = validateDocument({ ...like, object: [link, link] }); + const result = validateDocument({ ...LIKE, object: [link, link] }); assert.equal(result.complete, true); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -180,11 +188,11 @@ describe("validateDocument() supported document checks", () => { for (const diagnostic of result.diagnostics) { assert.equal( diagnostic.category, - diagnosticReasons[diagnostic.reason].category, + DIAGNOSTIC_REASONS[diagnostic.reason].category, ); assert.equal( diagnostic.severity, - diagnosticReasons[diagnostic.reason].severity, + DIAGNOSTIC_REASONS[diagnostic.reason].severity, ); assert.notEqual(diagnostic.message, ""); } @@ -208,11 +216,11 @@ describe("validateDocument() supported document checks", () => { it("checks aliased nested values at original indexes without expanding away nulls", () => { const input = { "@context": [ - context, - { payload: { "@id": `${context}#object`, "@type": "@id" } }, + CONTEXT, + { payload: { "@id": `${CONTEXT}#object`, "@type": "@id" } }, ], type: "Undo", - payload: [null, like.object, 123, { type: "Like", payload: false }], + payload: [null, LIKE.object, 123, { type: "Like", payload: false }], }; const original = structuredClone(input); const result = validateDocument(input); @@ -230,9 +238,9 @@ describe("validateDocument() supported document checks", () => { it("resolves type/id aliases, prefixes, full IRIs, and language containers", () => { const input = { "@context": [ - context, + CONTEXT, { - v: { "@id": `${context}#`, "@prefix": true }, + v: { "@id": `${CONTEXT}#`, "@prefix": true }, kind: "@type", identifier: "@id", title: "v:name", @@ -240,10 +248,10 @@ describe("validateDocument() supported document checks", () => { }, ], kind: "v:Note", - identifier: like.object, + identifier: LIKE.object, title: "Title", titles: { en: "Title", ko: "제목" }, - [`${context}#summary`]: "

Summary

", + [`${CONTEXT}#summary`]: "

Summary

", "v:content": "

Content

", }; assert.deepEqual(validateDocument(input), { @@ -268,20 +276,20 @@ describe("validateDocument() supported document checks", () => { it("isolates nested alias overrides from their siblings", () => { const input = { "@context": [ - context, - { payload: { "@id": `${context}#object`, "@type": "@id" } }, + CONTEXT, + { payload: { "@id": `${CONTEXT}#object`, "@type": "@id" } }, ], type: "Undo", payload: [ { "@context": { - payload: { "@id": `${context}#location`, "@type": "@id" }, + payload: { "@id": `${CONTEXT}#location`, "@type": "@id" }, }, type: "Arrive", - payload: like.object, + payload: LIKE.object, }, { type: "Like", payload: false }, - { type: "Arrive", payload: like.object }, + { type: "Arrive", payload: LIKE.object }, ], }; const original = structuredClone(input); @@ -300,8 +308,8 @@ describe("validateDocument() supported document checks", () => { it("counts functional aliases together without merging their original values", () => { const input = { "@context": [ - context, - { when: { "@id": `${context}#published`, "@type": "xsd:dateTime" } }, + CONTEXT, + { when: { "@id": `${CONTEXT}#published`, "@type": "xsd:dateTime" } }, ], type: "Note", published: "2026-10-27T12:00:00Z", @@ -331,12 +339,12 @@ describe("validateDocument() supported document checks", () => { for (const [key, inline] of [ ["payload", { payload: "as:object" }], ["as:object", {}], - [`${context}#object`, {}], + [`${CONTEXT}#object`, {}], ] as const) { const result = validateDocument({ - "@context": [context, inline], + "@context": [CONTEXT, inline], type: "Like", - [key]: like.object, + [key]: LIKE.object, }); assert.equal(result.complete, false); assert.deepEqual( @@ -352,7 +360,7 @@ describe("validateDocument() supported document checks", () => { { debugData: "https://example.com/ns#debugData" }, ]) { const input = { - "@context": [context, inline], + "@context": [CONTEXT, inline], type: "Note", debugData: { value: [null, 123], type: "custom" }, }; @@ -368,7 +376,7 @@ describe("validateDocument() supported document checks", () => { assert.equal(result.normalizedDocument, undefined); } const known = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", duration: "not-a-duration", }); @@ -392,8 +400,8 @@ describe("validateDocument() supported document checks", () => { ]; const input = { "@context": [ - context, - { reply: { "@id": `${context}#inReplyTo`, "@type": "@id" } }, + CONTEXT, + { reply: { "@id": `${CONTEXT}#inReplyTo`, "@type": "@id" } }, ], type: "Create", object: [ @@ -428,7 +436,7 @@ describe("validateDocument() supported document checks", () => { it("preserves opaque nested extensions in independent successful previews", () => { const data = { type: "Custom", values: [[], null, { content: 123 }] }; const input = { - ...like, + ...LIKE, object: { type: "Note", to: [], debugData: data }, debugData: data, }; @@ -476,7 +484,7 @@ describe("validateDocument() supported document checks", () => { [NaN], new Date(), ]) { - const result = validateDocument({ ...like, to: [], debugData }); + const result = validateDocument({ ...LIKE, to: [], debugData }); assert.ok( result.diagnostics.some(({ reason }) => reason === "non_json_value"), ); @@ -488,11 +496,11 @@ describe("validateDocument() supported document checks", () => { it("keeps JSON-LD keywords and incompatible standard aliases blocking", () => { for (const input of [ - { ...like, "@graph": [] }, + { ...LIKE, "@graph": [] }, { - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { payload: { "@id": "as:object", "@container": "@list" } }, ], payload: [], @@ -511,19 +519,19 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected fetch"); }); for (const declaration of [ - context, - [context], - [context, "https://w3id.org/security/v1"], + CONTEXT, + [CONTEXT], + [CONTEXT, "https://w3id.org/security/v1"], ]) { - assert.deepEqual(validateDocument({ ...like, "@context": declaration }), { + assert.deepEqual(validateDocument({ ...LIKE, "@context": declaration }), { complete: true, diagnostics: [], changes: [], }); } const blocked = validateDocument({ - ...like, - "@context": [context, "https://remote.example/context"], + ...LIKE, + "@context": [CONTEXT, "https://remote.example/context"], }); assert.equal(blocked.complete, false); assert.deepEqual( @@ -574,8 +582,8 @@ describe("validateDocument() supported document checks", () => { ], ] as const) { const result = validateDocument({ - ...like, - "@context": [context, inline], + ...LIKE, + "@context": [CONTEXT, inline], }); assert.deepEqual( result.diagnostics.map(({ reason: actual, path }) => ({ @@ -586,8 +594,8 @@ describe("validateDocument() supported document checks", () => { ); } const cyclic = validateDocument({ - ...like, - "@context": [context, { a: "b", b: "a" }], + ...LIKE, + "@context": [CONTEXT, { a: "b", b: "a" }], }); assert.ok( cyclic.diagnostics.every(({ reason }) => reason === "invalid_context"), @@ -623,7 +631,7 @@ describe("validateDocument() supported document checks", () => { { custom: `https://example.com/a${character}b` }, { ns: "https://example.com/", custom: `ns:a${character}b` }, ]) { - const input = { ...like, "@context": [context, inline] }; + const input = { ...LIKE, "@context": [CONTEXT, inline] }; const original = structuredClone(input); const result = validateDocument(input); assert.equal(result.complete, true); @@ -645,7 +653,7 @@ describe("validateDocument() supported document checks", () => { it("checks language value objects in opaque extensions at original nested paths", () => { for (const value of [123, false, [], {}]) { const input = { - ...like, + ...LIKE, object: { type: "Note", custom: [{ data: { "@value": value, "@language": "en" } }], @@ -672,7 +680,7 @@ describe("validateDocument() supported document checks", () => { assert.equal(result.normalizedDocument, undefined); } const invalidLanguage = validateDocument({ - ...like, + ...LIKE, custom: { "@value": "Hello", "@language": 123 }, }); assert.ok( @@ -696,7 +704,7 @@ describe("validateDocument() supported document checks", () => { ["@type", ["https://example.com/Type", 5], [1]], ] as const) { const input = { - ...like, + ...LIKE, to: [], object: { type: "Note", custom: [{ data: { [keyword]: value } }] }, }; @@ -723,13 +731,13 @@ describe("validateDocument() supported document checks", () => { assert.rejects( APObject.fromJsonLd( { - "@context": [context, { custom: "https://example.com/custom" }], + "@context": [CONTEXT, { custom: "https://example.com/custom" }], type: "Note", custom: { [keyword]: 5 }, }, { contextLoader: (url) => { - assert.equal(url, context); + assert.equal(url, CONTEXT); return Promise.resolve({ contextUrl: null, documentUrl: url, @@ -764,7 +772,7 @@ describe("validateDocument() supported document checks", () => { { "@type": ["@json"], "@value": { "@id": 5, "@type": {} } }, ]) { const input = { - "@context": [context, { custom: "https://example.com/custom" }], + "@context": [CONTEXT, { custom: "https://example.com/custom" }], type: "Note", to: [], custom, @@ -784,7 +792,7 @@ describe("validateDocument() supported document checks", () => { previews.map((preview) => APObject.fromJsonLd(preview, { contextLoader: (url) => { - assert.equal(url, context); + assert.equal(url, CONTEXT); return Promise.resolve({ contextUrl: null, documentUrl: url, @@ -820,7 +828,7 @@ describe("validateDocument() supported document checks", () => { { "@type": "@json", "@value": accessor }, { "@type": types }, ]) { - const result = validateDocument({ ...like, custom, to: [] }); + const result = validateDocument({ ...LIKE, custom, to: [] }); assert.ok( result.diagnostics.some(({ reason }) => reason === "non_json_value"), ); @@ -832,8 +840,8 @@ describe("validateDocument() supported document checks", () => { it("preserves safe language extensions and escaped context IRIs in previews", async () => { const input = { - ...like, - "@context": [context, { custom: "https://example.com/a%20b" }], + ...LIKE, + "@context": [CONTEXT, { custom: "https://example.com/a%20b" }], to: [], custom: [ { "@value": "Hello", "@language": "en" }, @@ -871,8 +879,8 @@ describe("validateDocument() supported document checks", () => { }, }); const result = validateDocument({ - ...like, - "@context": [context, { payload: definition }], + ...LIKE, + "@context": [CONTEXT, { payload: definition }], }); assert.equal(evaluated, false); assert.equal(result.complete, true); @@ -885,7 +893,7 @@ describe("validateDocument() supported document checks", () => { it("uses original addressing aliases in normalized previews and change paths", () => { const input = { "@context": [ - context, + CONTEXT, { recipients: { "@id": "as:to", "@type": "@id" }, payload: { "@id": "as:object", "@type": "@id" }, @@ -918,11 +926,11 @@ describe("validateDocument() supported document checks", () => { const fetch = t.mock.method(globalThis, "fetch", () => { throw new Error("Unexpected fetch"); }); - const bundled = structuredClone(preloadedContexts[context]); + const bundled = structuredClone(preloadedContexts[CONTEXT]); const input = { "@context": [ - context, - { payload: { "@id": "z:object", "@type": "@id" }, z: `${context}#` }, + CONTEXT, + { payload: { "@id": "z:object", "@type": "@id" }, z: `${CONTEXT}#` }, ], type: "z:Like", payload: [null, 123, { type: "Like", payload: false }], @@ -930,22 +938,22 @@ describe("validateDocument() supported document checks", () => { const original = structuredClone(input); const expanded = await jsonld.expand(input, { documentLoader: (url: string) => { - assert.equal(url, context); + assert.equal(url, CONTEXT); return Promise.resolve({ contextUrl: null, documentUrl: url, - document: structuredClone(preloadedContexts[context]), + document: structuredClone(preloadedContexts[CONTEXT]), }); }, }); assert.deepEqual(expanded, [ { - "@type": [`${context}#Like`], - [`${context}#object`]: [ + "@type": [`${CONTEXT}#Like`], + [`${CONTEXT}#object`]: [ { "@value": 123 }, { - "@type": [`${context}#Like`], - [`${context}#object`]: [{ "@value": false }], + "@type": [`${CONTEXT}#Like`], + [`${CONTEXT}#object`]: [{ "@value": false }], }, ], }, @@ -959,7 +967,7 @@ describe("validateDocument() supported document checks", () => { { reason: "invalid_value_shape", path: ["payload", 2, "payload"] }, ], ); - assert.deepEqual(preloadedContexts[context], bundled); + assert.deepEqual(preloadedContexts[CONTEXT], bundled); assert.deepEqual(input, original); assert.equal(fetch.mock.callCount(), 0); }); @@ -974,7 +982,7 @@ describe("validateDocument() supported document checks", () => { ]) { assert.deepEqual( validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", [property]: value, }), @@ -987,7 +995,7 @@ describe("validateDocument() supported document checks", () => { "2026-10-27T12:34:56.1234567891+05:30", ]) { const input = Object.freeze({ - "@context": context, + "@context": CONTEXT, type: "Note", [property]: Object.freeze([null, value]), }); @@ -1015,7 +1023,7 @@ describe("validateDocument() supported document checks", () => { "2026-01-01", ]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", [property]: [null, value], }); @@ -1027,7 +1035,7 @@ describe("validateDocument() supported document checks", () => { } } const leap = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", published: "2016-12-31T23:59:60Z", }); @@ -1042,15 +1050,15 @@ describe("validateDocument() supported document checks", () => { "2024-02-29T12:34:56.1-00:00", "2024-02-29T12:34:56.123456789+05:30", ].map(async (published) => { - const input = { "@context": context, type: "Note", published }; + const input = { "@context": CONTEXT, type: "Note", published }; assert.deepEqual(validateDocument(input).diagnostics, []); const parsed = await APObject.fromJsonLd(input, { contextLoader: (url) => { - assert.equal(url, context); + assert.equal(url, CONTEXT); return Promise.resolve({ contextUrl: null, documentUrl: url, - document: structuredClone(preloadedContexts[context]), + document: structuredClone(preloadedContexts[CONTEXT]), }); }, }); @@ -1064,7 +1072,7 @@ describe("validateDocument() supported document checks", () => { it("checks functional values and preserves scalar, list, and null-entry paths", () => { for (const [type, property, value] of [ - ["Link", "href", like.object], + ["Link", "href", LIKE.object], ["Link", "width", 0], ["Link", "height", 1], ["Link", "mediaType", "text/html"], @@ -1074,7 +1082,7 @@ describe("validateDocument() supported document checks", () => { ["CollectionPage", "next", { type: "CollectionPage" }], ["Person", "inbox", { type: "OrderedCollection" }], ] as const) { - const input = { "@context": context, type, [property]: [null, value] }; + const input = { "@context": CONTEXT, type, [property]: [null, value] }; const original = structuredClone(input); assert.deepEqual(validateDocument(input), { complete: true, @@ -1097,13 +1105,13 @@ describe("validateDocument() supported document checks", () => { it("rejects identifier arrays containing values at their original property paths", () => { for (const property of ["id", "@id", "identity"]) { for (const value of [ - [like.object], - [null, like.object], - [like.object, like.object], + [LIKE.object], + [null, LIKE.object], + [LIKE.object, LIKE.object], ]) { const input = { - ...like, - "@context": [context, { identity: "@id" }], + ...LIKE, + "@context": [CONTEXT, { identity: "@id" }], [property]: value, }; const original = structuredClone(input); @@ -1125,8 +1133,8 @@ describe("validateDocument() supported document checks", () => { for (const property of ["id", "@id", "identity"]) { for (const value of [null, [], [null], [null, null]]) { const input = { - ...like, - "@context": [context, { identity: "@id" }], + ...LIKE, + "@context": [CONTEXT, { identity: "@id" }], object: [{ type: "Note", [property]: value }], }; const original = structuredClone(input); @@ -1173,7 +1181,7 @@ describe("validateDocument() supported document checks", () => { previews.map((preview) => APObject.fromJsonLd(preview, { documentLoader: (url) => { - assert.equal(url, context); + assert.equal(url, CONTEXT); return Promise.resolve({ contextUrl: null, documentUrl: url, @@ -1198,7 +1206,7 @@ describe("validateDocument() supported document checks", () => { const sparse = [null]; sparse.length = 2; for (const id of [accessor, sparse]) { - const result = validateDocument({ ...like, id }); + const result = validateDocument({ ...LIKE, id }); assert.ok( result.diagnostics.some(({ reason }) => reason === "non_json_value"), ); @@ -1218,14 +1226,14 @@ describe("validateDocument() supported document checks", () => { ]) { assert.deepEqual( validateDocument({ - "@context": context, + "@context": CONTEXT, type, preferredUsername: [null, "alice"], }), { complete: true, diagnostics: [], changes: [] }, ); const result = validateDocument({ - ...like, + ...LIKE, actor: { type, preferredUsername: false }, }); assert.equal(result.complete, true); @@ -1241,7 +1249,7 @@ describe("validateDocument() supported document checks", () => { } for (const type of ["Note", "Link"]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type, preferredUsername: "alice", }); @@ -1260,7 +1268,7 @@ describe("validateDocument() supported document checks", () => { }, }); const result = validateDocument({ - ...like, + ...LIKE, object: { type: "Note", contentMap: map, @@ -1363,7 +1371,7 @@ describe("validateDocument() supported document checks", () => { for (const [type, properties] of cases) { for (const property of properties) { const input = Object.freeze({ - "@context": context, + "@context": CONTEXT, type, [property]: Object.freeze([]), }); @@ -1401,7 +1409,7 @@ describe("validateDocument() supported document checks", () => { ], ); assert.deepEqual(result.normalizedDocument, { - "@context": context, + "@context": CONTEXT, type, }); assert.deepEqual(input[property], []); @@ -1416,9 +1424,9 @@ describe("validateDocument() supported document checks", () => { it("uses original nested alias paths for empty-property changes and retains custom arrays", () => { const input = { - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { title: "as:name", payload: { "@id": "as:object", "@type": "@id" } }, ], object: [{ type: "Like", title: [], payload: [], custom: [] }], @@ -1457,7 +1465,7 @@ describe("validateDocument() supported document checks", () => { accessor, symbol, ]) { - const result = validateDocument({ ...like, object: value }); + const result = validateDocument({ ...LIKE, object: value }); assert.ok( result.diagnostics.some(({ reason }) => reason === "non_json_value"), ); @@ -1466,10 +1474,10 @@ describe("validateDocument() supported document checks", () => { } assert.equal(evaluated, false); for (const input of [ - { ...like, type: [] }, - { ...like, "@context": [] }, - { ...like, duration: [] }, - { "@context": context, type: "Link", content: [] }, + { ...LIKE, type: [] }, + { ...LIKE, "@context": [] }, + { ...LIKE, duration: [] }, + { "@context": CONTEXT, type: "Link", content: [] }, ]) { const result = validateDocument(input); assert.ok( @@ -1484,14 +1492,14 @@ describe("validateDocument() supported document checks", () => { const fetch = t.mock.method(globalThis, "fetch", () => { throw new Error("Unexpected network access"); }); - const recipients = Object.freeze([null, like.actor, like.actor]); + const recipients = Object.freeze([null, LIKE.actor, LIKE.actor]); const object = Object.freeze({ type: "Note", to: Object.freeze([]), cc: recipients, }); const input = Object.freeze({ - ...like, + ...LIKE, bcc: Object.freeze([]), object: Object.freeze([object]), }); @@ -1521,8 +1529,8 @@ describe("validateDocument() supported document checks", () => { ], ); assert.deepEqual(result.normalizedDocument, { - ...like, - object: [{ type: "Note", cc: [null, like.actor, like.actor] }], + ...LIKE, + object: [{ type: "Note", cc: [null, LIKE.actor, LIKE.actor] }], }); assert.deepEqual(input, original); const previewObject = ( @@ -1539,13 +1547,13 @@ describe("validateDocument() supported document checks", () => { { duration: "PT1H" }, { actor: Array(110).fill(false) }, ]) { - const result = validateDocument({ ...like, to: [], ...fields }); + const result = validateDocument({ ...LIKE, to: [], ...fields }); assert.equal(result.normalizedDocument, undefined); assert.deepEqual(result.changes, []); } for (const property of ["to", "cc", "bto", "bcc", "audience"]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Link", [property]: [], }); @@ -1559,7 +1567,7 @@ describe("validateDocument() supported document checks", () => { for (let index = 0; index < 63; index += 1) { input = { type: "Create", object: input }; } - input["@context"] = context; + input["@context"] = CONTEXT; const result = validateDocument(input); assert.equal(result.complete, true); assert.equal(result.diagnostics.length, 1); @@ -1582,7 +1590,7 @@ describe("validateDocument() supported document checks", () => { it("keeps optional fields optional and supports null as absence", () => { assert.deepEqual( - validateDocument({ "@context": context, type: "Like", object: null }), + validateDocument({ "@context": CONTEXT, type: "Like", object: null }), { complete: true, diagnostics: [], @@ -1601,7 +1609,7 @@ describe("validateDocument() supported document checks", () => { }); it("reports independent shape errors in source-key order", () => { - const result = validateDocument({ ...like, object: 123, actor: false }); + const result = validateDocument({ ...LIKE, object: 123, actor: false }); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), [ @@ -1614,10 +1622,10 @@ describe("validateDocument() supported document checks", () => { it("does not claim coverage of unimplemented fields or nested values", () => { for (const input of [ - { ...like, duration: "PT1H" }, - { ...like, object: { type: "Page", content: "Hello" } }, - { ...like, type: "Page" }, - { ...like, type: ["Like", "https://example.com/CustomLike"] }, + { ...LIKE, duration: "PT1H" }, + { ...LIKE, object: { type: "Page", content: "Hello" } }, + { ...LIKE, type: "Page" }, + { ...LIKE, type: ["Like", "https://example.com/CustomLike"] }, ]) { const original = structuredClone(input); const result = validateDocument(input); @@ -1634,7 +1642,7 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const result = validateDocument({ - ...like, + ...LIKE, "@context": "https://remote.example/context", object: 123, }); @@ -1651,12 +1659,12 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const input = Object.freeze({ - ...like, - actor: Object.freeze([like.actor, "https://example.com/bob", like.actor]), + ...LIKE, + actor: Object.freeze([LIKE.actor, "https://example.com/bob", LIKE.actor]), object: Object.freeze([ - like.object, + LIKE.object, "https://example.com/posts/43", - like.object, + LIKE.object, ]), }); const original = structuredClone(input); @@ -1671,9 +1679,9 @@ describe("validateDocument() supported document checks", () => { it("reports independent actor and object list failures at their original indexes", () => { const result = validateDocument({ - ...like, - actor: [like.actor, false], - object: [123, like.object, "https://example.com/not valid"], + ...LIKE, + actor: [LIKE.actor, false], + object: [123, LIKE.object, "https://example.com/not valid"], }); assert.equal(result.complete, true); assert.deepEqual( @@ -1688,8 +1696,8 @@ describe("validateDocument() supported document checks", () => { it("checks reference siblings while reporting unimplemented list entries", () => { const result = validateDocument({ - ...like, - object: [like.object, { type: "Page", content: "Hello" }, 123], + ...LIKE, + object: [LIKE.object, { type: "Page", content: "Hello" }, 123], }); assert.equal(result.complete, false); assert.deepEqual( @@ -1706,18 +1714,18 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const input = Object.freeze({ - ...like, + ...LIKE, type: "Create", object: Object.freeze([ - like.object, + LIKE.object, Object.freeze({ - type: `${context}#Note`, - id: like.object, - attributedTo: Object.freeze([like.actor, false]), + type: `${CONTEXT}#Note`, + id: LIKE.object, + attributedTo: Object.freeze([LIKE.actor, false]), content: 123, }), Object.freeze({ type: "Note", content: "" }), - like.object, + LIKE.object, ]), }); const original = structuredClone(input); @@ -1742,7 +1750,7 @@ describe("validateDocument() supported document checks", () => { it("supports standalone Notes and optional or empty content", () => { for (const content of [undefined, null, "", "

Hello

"]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", ...(content === undefined ? {} : { content }), }); @@ -1753,18 +1761,18 @@ describe("validateDocument() supported document checks", () => { }); } assert.equal( - validateDocument({ ...like, object: { type: "Note" } }).complete, + validateDocument({ ...LIKE, object: { type: "Note" } }).complete, true, ); assert.equal( - validateDocument({ ...like, type: `${context}#Create` }).complete, + validateDocument({ ...LIKE, type: `${CONTEXT}#Create` }).complete, true, ); }); it("blocks an unknown nested context but still checks sibling objects", () => { const result = validateDocument({ - ...like, + ...LIKE, type: "Create", object: [ { @@ -1791,7 +1799,7 @@ describe("validateDocument() supported document checks", () => { it("requires the language-map property name for an untagged language object", () => { for (const content of [{ en: "Hello" }]) { const result = validateDocument({ - ...like, + ...LIKE, object: { type: "Note", content }, }); assert.equal(result.complete, false); @@ -1799,7 +1807,7 @@ describe("validateDocument() supported document checks", () => { assert.deepEqual(result.diagnostics[0]?.path, ["object", "content"]); } const result = validateDocument({ - ...like, + ...LIKE, object: { type: "Note", name: "Hello" }, }); assert.equal(result.complete, true); @@ -1809,7 +1817,7 @@ describe("validateDocument() supported document checks", () => { it("does not invoke embedded object getters", () => { let evaluated = false; const result = validateDocument({ - ...like, + ...LIKE, object: { type: "Note", get content() { @@ -1825,7 +1833,7 @@ describe("validateDocument() supported document checks", () => { it("shares the diagnostic cap across embedded Notes", () => { const result = validateDocument({ - ...like, + ...LIKE, object: Array.from({ length: 110 }, () => ({ type: "Note", content: 123, @@ -1839,18 +1847,18 @@ describe("validateDocument() supported document checks", () => { it("checks nested activities depth first, preserving list and property paths", () => { const input = { - "@context": context, - type: `${context}#Accept`, + "@context": CONTEXT, + type: `${CONTEXT}#Accept`, actor: false, object: [ { type: "Undo", - actor: like.actor, - object: { type: `${context}#Like`, object: 123 }, + actor: LIKE.actor, + object: { type: `${CONTEXT}#Like`, object: 123 }, published: "2026-10-26T00:00:00Z", }, 456, - { type: `${context}#Follow`, actor: false, object: like.actor }, + { type: `${CONTEXT}#Follow`, actor: false, object: LIKE.actor }, ], published: "2026-10-26T00:00:00Z", }; @@ -1880,7 +1888,7 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); for (const type of ["Undo", "Accept", "Follow"]) { - assert.deepEqual(validateDocument({ ...like, type }), { + assert.deepEqual(validateDocument({ ...LIKE, type }), { complete: true, diagnostics: [], changes: [], @@ -1891,7 +1899,7 @@ describe("validateDocument() supported document checks", () => { it("blocks a nested activity context without skipping later siblings", () => { const result = validateDocument({ - ...like, + ...LIKE, type: "Undo", object: [ { @@ -1917,7 +1925,7 @@ describe("validateDocument() supported document checks", () => { it("reports cycles without rejecting shared objects in separate branches", () => { const cyclic: Record = { - "@context": context, + "@context": CONTEXT, type: "Undo", }; cyclic.object = cyclic; @@ -1929,7 +1937,7 @@ describe("validateDocument() supported document checks", () => { ); const shared = Object.freeze({ type: "Follow", actor: false }); const result = validateDocument({ - ...like, + ...LIKE, type: "Accept", object: [shared, shared], }); @@ -1951,7 +1959,7 @@ describe("validateDocument() supported document checks", () => { input = { type: "Create", object: input }; } if (list) input = { type: "Create", object: [input] }; - input["@context"] = context; + input["@context"] = CONTEXT; const original = structuredClone(input); const result = validateDocument(input); assert.equal(result.complete, depth === 64); @@ -1989,7 +1997,7 @@ describe("validateDocument() supported document checks", () => { value = shape === "object" ? { child: value } : [value]; } if (shape === "json") value = { "@type": "@json", "@value": value }; - const input = { "@context": context, type: "Note", debugData: value }; + const input = { "@context": CONTEXT, type: "Note", debugData: value }; const original = structuredClone(input); const result = validateDocument(input); assert.equal(result.complete, depth === 64); @@ -2031,7 +2039,7 @@ describe("validateDocument() supported document checks", () => { for (let index = 0; index < 65; index += 1) { input = { type: "Create", object: input }; } - input["@context"] = context; + input["@context"] = CONTEXT; input.result = false; input.to = []; const result = validateDocument(input); @@ -2054,7 +2062,7 @@ describe("validateDocument() supported document checks", () => { let opaque: unknown = leaf; for (let index = 0; index < 64; index += 1) opaque = { child: opaque }; const extension = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", debugData: opaque, to: [], @@ -2087,7 +2095,7 @@ describe("validateDocument() supported document checks", () => { }, }); object.length = 3; - const result = validateDocument({ ...like, type: "Undo", object }); + const result = validateDocument({ ...LIKE, type: "Undo", object }); assert.equal(evaluated, false); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -2107,9 +2115,9 @@ describe("validateDocument() supported document checks", () => { "Travel", "Question", ]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2123,12 +2131,12 @@ describe("validateDocument() supported document checks", () => { it("does not admit types recognized by Fedify until their checker rules are implemented", () => { for (const type of ["Page", "Place", "Image", "Event"]) { assert.ok( - getEntityTypeById(`${context}#${type}`), + getEntityTypeById(`${CONTEXT}#${type}`), `Fedify must recognize ${type}`, ); - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: spelling, }); assert.equal(result.complete, false); @@ -2158,9 +2166,9 @@ describe("validateDocument() supported document checks", () => { "IntransitiveActivity", "Arrive", ]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2176,14 +2184,14 @@ describe("validateDocument() supported document checks", () => { it("keeps unknown and foreign type IRIs unsupported instead of guessing from their local names", () => { for (const type of [ "CustomLike", - `${context}#CustomLike`, + `${CONTEXT}#CustomLike`, "https://example.com/Like", ]) { assert.equal( - getEntityTypeById(type.includes(":") ? type : `${context}#${type}`), + getEntityTypeById(type.includes(":") ? type : `${CONTEXT}#${type}`), undefined, ); - const result = validateDocument({ "@context": context, type }); + const result = validateDocument({ "@context": CONTEXT, type }); assert.equal(result.complete, false); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -2218,10 +2226,10 @@ describe("validateDocument() supported document checks", () => { "Update", "View", ]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { // Vocabulary properties remain optional, including actor and object. assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2229,12 +2237,12 @@ describe("validateDocument() supported document checks", () => { }, ); const input = { - "@context": context, + "@context": CONTEXT, type: spelling, actor: false, instrument: 123, object: [{ type: "Note", content: false }], - origin: [like.object, false], + origin: [LIKE.object, false], result: 123, target: false, }; @@ -2260,12 +2268,12 @@ describe("validateDocument() supported document checks", () => { it("recognizes inherited activity families in nested new types without applying their social effects", () => { const input = { - ...like, + ...LIKE, type: "TentativeAccept", object: [ { type: "Invite", - object: { type: "Block", actor: false, object: like.actor }, + object: { type: "Block", actor: false, object: LIKE.actor }, }, { type: "TentativeReject", @@ -2294,9 +2302,9 @@ describe("validateDocument() supported document checks", () => { it("uses shared Object rules for Object and Article without activity properties", () => { for (const type of ["Object", "Article"]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2305,7 +2313,7 @@ describe("validateDocument() supported document checks", () => { ); for (const content of [null, "", "

Text

"]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling, content }), + validateDocument({ "@context": CONTEXT, type: spelling, content }), { complete: true, diagnostics: [], @@ -2314,11 +2322,11 @@ describe("validateDocument() supported document checks", () => { ); } const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: spelling, - actor: like.actor, + actor: LIKE.actor, content: false, - object: like.object, + object: LIKE.object, }); assert.equal(result.complete, false); assert.deepEqual( @@ -2339,19 +2347,19 @@ describe("validateDocument() supported document checks", () => { }); for (const type of ["Object", "Article"]) { const embedded = Object.freeze({ - type: `${context}#${type}`, - id: like.object, + type: `${CONTEXT}#${type}`, + id: LIKE.object, content: "", - attributedTo: Object.freeze([like.actor, like.actor]), + attributedTo: Object.freeze([LIKE.actor, LIKE.actor]), location: Object.freeze([ - like.object, + LIKE.object, Object.freeze({ type: "Object", content: "Place description" }), ]), }); const input = Object.freeze({ - ...like, + ...LIKE, type: "Update", - object: Object.freeze([like.object, embedded, like.object]), + object: Object.freeze([LIKE.object, embedded, LIKE.object]), }); const original = structuredClone(input); assert.deepEqual(validateDocument(input), { @@ -2362,10 +2370,10 @@ describe("validateDocument() supported document checks", () => { assert.deepEqual(input, original); } const result = validateDocument({ - ...like, + ...LIKE, type: "Create", object: [ - { type: "Object", attributedTo: [like.actor, false] }, + { type: "Object", attributedTo: [LIKE.actor, false] }, { type: "Article", location: [{ type: "Object", content: 123 }] }, ], }); @@ -2397,9 +2405,9 @@ describe("validateDocument() supported document checks", () => { "Person", "Service", ]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2408,12 +2416,12 @@ describe("validateDocument() supported document checks", () => { ); const actor = Object.freeze({ type: spelling, - id: like.actor, + id: LIKE.actor, content: "", }); const input = Object.freeze({ - ...like, - actor: Object.freeze([like.actor, actor, like.actor]), + ...LIKE, + actor: Object.freeze([LIKE.actor, actor, LIKE.actor]), object: Object.freeze({ type: "Article", attributedTo: actor, @@ -2434,7 +2442,7 @@ describe("validateDocument() supported document checks", () => { it("reports unsupported actor ranges separately from nested vocabulary errors", () => { const result = validateDocument({ - ...like, + ...LIKE, actor: [ { type: "Note", content: 123 }, { type: "Person", id: false }, @@ -2474,7 +2482,7 @@ describe("validateDocument() supported document checks", () => { it("checks embedded Link values while keeping unimplemented actor fields explicit", () => { const result = validateDocument({ - ...like, + ...LIKE, actor: [ { type: "Link", href: 123 }, { @@ -2507,15 +2515,15 @@ describe("validateDocument() supported document checks", () => { ]) { for (const embeddedType of ["Link", "Mention"]) { const input = { - ...like, + ...LIKE, "@context": [ - context, - { relation: { "@id": `${context}#${property}`, "@type": "@id" } }, + CONTEXT, + { relation: { "@id": `${CONTEXT}#${property}`, "@type": "@id" } }, ], object: [ { type, - relation: [null, { type: embeddedType, href: like.actor }], + relation: [null, { type: embeddedType, href: LIKE.actor }], }, ], }; @@ -2568,7 +2576,7 @@ describe("validateDocument() supported document checks", () => { "bcc", ]; const input = { - "@context": context, + "@context": CONTEXT, type, ...Object.fromEntries(properties.map((property) => [property, true])), }; @@ -2619,7 +2627,7 @@ describe("validateDocument() supported document checks", () => { ]; const input = { "@context": [ - context, + CONTEXT, { author: { "@id": "as:attributedTo", "@type": "@id" } }, ], type, @@ -2647,7 +2655,7 @@ describe("validateDocument() supported document checks", () => { it("preserves Link language maps and embedded previews supported by Fedify", async () => { const input = { - "@context": context, + "@context": CONTEXT, type: "Link", nameMap: { en: "Hello", ko: "안녕" }, preview: { type: "Note", content: "Preview" }, @@ -2674,9 +2682,9 @@ describe("validateDocument() supported document checks", () => { }); it("detects actor/attribution cycles without rejecting shared actors in separate branches", () => { - const actor: Record = { type: "Person", id: like.actor }; + const actor: Record = { type: "Person", id: LIKE.actor }; actor.attributedTo = actor; - const result = validateDocument({ ...like, actor: [actor, actor] }); + const result = validateDocument({ ...LIKE, actor: [actor, actor] }); assert.equal(result.complete, true); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -2697,10 +2705,10 @@ describe("validateDocument() supported document checks", () => { for (const object of [ false, 123, - like.object, + LIKE.object, { type: "Note", content: false }, ]) { - const result = validateDocument({ "@context": context, type, object }); + const result = validateDocument({ "@context": CONTEXT, type, object }); assert.equal(result.complete, true); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -2708,7 +2716,7 @@ describe("validateDocument() supported document checks", () => { ); } assert.deepEqual( - validateDocument({ "@context": context, type, object: [null] }), + validateDocument({ "@context": CONTEXT, type, object: [null] }), { complete: true, diagnostics: [], @@ -2716,9 +2724,9 @@ describe("validateDocument() supported document checks", () => { }, ); const listed = validateDocument({ - "@context": context, + "@context": CONTEXT, type, - object: [null, like.object], + object: [null, LIKE.object], }); assert.equal(listed.complete, true); assert.deepEqual( @@ -2730,22 +2738,22 @@ describe("validateDocument() supported document checks", () => { it("checks nested intransitive activities while continuing independent fields and siblings", () => { const input = { - ...like, + ...LIKE, type: "Activity", object: [ { - type: `${context}#Arrive`, + type: `${CONTEXT}#Arrive`, actor: false, - location: [like.object, 123], + location: [LIKE.object, 123], object: { type: "Note", content: false }, origin: false, }, { - type: `${context}#IntransitiveActivity`, + type: `${CONTEXT}#IntransitiveActivity`, object: null, result: { type: "Activity", object: 123 }, }, - { type: "Activity", object: like.object }, + { type: "Activity", object: LIKE.object }, ], }; const original = structuredClone(input); @@ -2779,20 +2787,20 @@ describe("validateDocument() supported document checks", () => { "Question", ]) { const input = Object.freeze({ - "@context": context, + "@context": CONTEXT, type, - actor: like.actor, + actor: LIKE.actor, target: Object.freeze([ - like.object, + LIKE.object, Object.freeze({ type: "Note", content: "Hello" }), ]), - origin: like.object, - result: Object.freeze({ type: "Activity", object: like.object }), - instrument: like.object, + origin: LIKE.object, + result: Object.freeze({ type: "Activity", object: LIKE.object }), + instrument: LIKE.object, location: Object.freeze([ - like.object, + LIKE.object, Object.freeze({ type: "Note", content: "Place" }), - like.object, + LIKE.object, ]), }); const original = structuredClone(input); @@ -2805,7 +2813,7 @@ describe("validateDocument() supported document checks", () => { } for (const type of ["Note", "Like"]) { assert.deepEqual( - validateDocument({ "@context": context, type, location: null }), + validateDocument({ "@context": CONTEXT, type, location: null }), { complete: true, diagnostics: [], changes: [] }, ); } @@ -2814,7 +2822,7 @@ describe("validateDocument() supported document checks", () => { it("reports unsupported location types and inherited context overrides without guessing", () => { const result = validateDocument({ - ...like, + ...LIKE, type: "Arrive", object: null, location: [ @@ -2843,18 +2851,18 @@ describe("validateDocument() supported document checks", () => { it("checks standalone and nested Travel and Question through inherited rules", () => { const input = { - ...like, + ...LIKE, type: "Create", object: [ { - type: `${context}#Travel`, + type: `${CONTEXT}#Travel`, actor: false, object: { type: "Note", content: false }, - origin: [like.object, 123], - target: like.object, + origin: [LIKE.object, 123], + target: LIKE.object, }, { - type: `${context}#Question`, + type: `${CONTEXT}#Question`, object: false, result: { type: "Travel", object: 123 }, }, @@ -2885,7 +2893,7 @@ describe("validateDocument() supported document checks", () => { }); let evaluated = false; const result = validateDocument({ - ...like, + ...LIKE, type: "Create", object: [ { @@ -2926,9 +2934,9 @@ describe("validateDocument() supported document checks", () => { "CollectionPage", "OrderedCollectionPage", ]) { - for (const spelling of [type, `${context}#${type}`]) { + for (const spelling of [type, `${CONTEXT}#${type}`]) { assert.deepEqual( - validateDocument({ "@context": context, type: spelling }), + validateDocument({ "@context": CONTEXT, type: spelling }), { complete: true, diagnostics: [], @@ -2937,10 +2945,10 @@ describe("validateDocument() supported document checks", () => { ); assert.deepEqual( validateDocument({ - "@context": context, + "@context": CONTEXT, type: spelling, totalItems: 100, - items: like.object, + items: LIKE.object, }), { complete: true, @@ -2950,7 +2958,7 @@ describe("validateDocument() supported document checks", () => { ); for (const totalItems of [-1, 1.5, "2", false]) { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: spelling, totalItems, }); @@ -2964,7 +2972,7 @@ describe("validateDocument() supported document checks", () => { } assert.deepEqual( validateDocument({ - "@context": context, + "@context": CONTEXT, type: "OrderedCollectionPage", startIndex: null, totalItems: null, @@ -2982,16 +2990,16 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const members = Object.freeze([ - like.object, + LIKE.object, Object.freeze({ type: "Article", content: "Text" }), - like.object, + LIKE.object, ]); const target = Object.freeze({ - type: `${context}#OrderedCollection`, + type: `${CONTEXT}#OrderedCollection`, totalItems: 50, orderedItems: members, }); - const input = Object.freeze({ ...like, type: "Add", target }); + const input = Object.freeze({ ...LIKE, type: "Add", target }); const original = structuredClone(input); assert.deepEqual(validateDocument(input), { complete: true, @@ -3000,13 +3008,13 @@ describe("validateDocument() supported document checks", () => { }); assert.deepEqual(input, original); const result = validateDocument({ - ...like, + ...LIKE, type: "Add", target: { type: "OrderedCollection", totalItems: -1, orderedItems: [ - like.object, + LIKE.object, { type: "CollectionPage", items: [{ type: "Note", content: 123 }] }, false, ], @@ -3029,7 +3037,7 @@ describe("validateDocument() supported document checks", () => { it("checks embedded navigation ranges and permits single-value arrays", () => { const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "OrderedCollectionPage", current: { type: "OrderedCollectionPage", startIndex: -1 }, first: { type: "CollectionPage", items: 123 }, @@ -3053,9 +3061,9 @@ describe("validateDocument() supported document checks", () => { ], ); const unsupported = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", - items: like.object, + items: LIKE.object, totalItems: 1, }); assert.equal(unsupported.complete, false); @@ -3076,13 +3084,13 @@ describe("validateDocument() supported document checks", () => { ]) { assert.deepEqual( validateDocument({ - "@context": context, + "@context": CONTEXT, type, inbox: { type: "OrderedCollectionPage", startIndex: 0 }, outbox: "https://example.com/outbox", following: { type: "OrderedCollection", totalItems: 0 }, followers: { type: "CollectionPage" }, - liked: like.object, + liked: LIKE.object, }), { complete: true, diagnostics: [], changes: [] }, ); @@ -3090,7 +3098,7 @@ describe("validateDocument() supported document checks", () => { const page: Record = { type: "CollectionPage" }; page.next = page; const result = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Collection", first: page, }); @@ -3099,10 +3107,10 @@ describe("validateDocument() supported document checks", () => { result.diagnostics.map(({ reason, path }) => ({ reason, path })), [{ reason: "non_json_value", path: ["first", "next"] }], ); - const items = [like.object]; + const items = [LIKE.object]; items.length = 2; const sparse = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Collection", items, }); @@ -3118,16 +3126,16 @@ describe("validateDocument() supported document checks", () => { throw new Error("Unexpected network access"); }); const note = Object.freeze({ type: "Note", content: "Hello" }); - const activity = Object.freeze({ type: "Follow", object: like.actor }); + const activity = Object.freeze({ type: "Follow", object: LIKE.actor }); for (const type of ["Like", "Create", "Undo", "Accept", "Follow"]) { for (const value of [ null, - like.object, + LIKE.object, note, activity, - Object.freeze([like.object, note, activity, like.object]), + Object.freeze([LIKE.object, note, activity, LIKE.object]), ]) { - const input = Object.freeze({ ...like, type, [property]: value }); + const input = Object.freeze({ ...LIKE, type, [property]: value }); const original = structuredClone(input); assert.deepEqual(validateDocument(input), { complete: true, @@ -3142,7 +3150,7 @@ describe("validateDocument() supported document checks", () => { it(`checks ${property} list entries and nested properties in source order`, () => { const result = validateDocument({ - ...like, + ...LIKE, [property]: [ 123, { type: "Note", content: false }, @@ -3173,10 +3181,10 @@ describe("validateDocument() supported document checks", () => { it("keeps undecided relation forms unsupported and restricts the new properties to activities", () => { for (const property of ["target", "origin", "result", "instrument"]) { for (const [value, expectedPath] of [ - [[[like.object]], [property, 0]], + [[[LIKE.object]], [property, 0]], ["/relative", [property]], ] as const) { - const result = validateDocument({ ...like, [property]: value }); + const result = validateDocument({ ...LIKE, [property]: value }); assert.equal(result.complete, false); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -3184,9 +3192,9 @@ describe("validateDocument() supported document checks", () => { ); } const note = validateDocument({ - "@context": context, + "@context": CONTEXT, type: "Note", - [property]: like.object, + [property]: LIKE.object, }); assert.equal(note.complete, false); assert.deepEqual( @@ -3198,7 +3206,7 @@ describe("validateDocument() supported document checks", () => { it("applies nested context blocking and cycle detection to the new relations", () => { const input: Record = { - ...like, + ...LIKE, instrument: { "@context": "https://remote.example/context", type: "Note", @@ -3225,7 +3233,7 @@ describe("validateDocument() supported document checks", () => { }); it("keeps nested lists incomplete", () => { - const result = validateDocument({ ...like, object: [[like.object]] }); + const result = validateDocument({ ...LIKE, object: [[LIKE.object]] }); assert.equal(result.complete, false); assert.equal(result.diagnostics[0]?.reason, "unsupported_value_form"); assert.deepEqual(result.diagnostics[0]?.path, ["object", 0]); @@ -3233,7 +3241,7 @@ describe("validateDocument() supported document checks", () => { it("does not evaluate list accessors or silently skip sparse entries", () => { let evaluated = false; - const accessor = [like.object]; + const accessor = [LIKE.object]; Object.defineProperty(accessor, "0", { enumerable: true, get() { @@ -3241,13 +3249,13 @@ describe("validateDocument() supported document checks", () => { throw new Error("Do not invoke input accessors"); }, }); - const sparse = [like.object]; + const sparse = [LIKE.object]; sparse.length = 2; for (const [object, index] of [ [accessor, 0], [sparse, 1], ] as const) { - const result = validateDocument({ ...like, object }); + const result = validateDocument({ ...LIKE, object }); assert.equal(result.diagnostics[0]?.reason, "non_json_value"); assert.deepEqual(result.diagnostics[0]?.path, ["object", index]); } @@ -3256,15 +3264,15 @@ describe("validateDocument() supported document checks", () => { it("does not impose the deferred value-count budget on reference lists", () => { const result = validateDocument({ - ...like, - actor: Array(6000).fill(like.actor), - object: Array(6000).fill(like.object), + ...LIKE, + actor: Array(6000).fill(LIKE.actor), + object: Array(6000).fill(LIKE.object), }); assert.deepEqual(result, { complete: true, diagnostics: [], changes: [] }); }); it("reports an implicit context as a warning rather than rejection", () => { - const result = validateDocument({ type: "Like", object: like.object }); + const result = validateDocument({ type: "Like", object: LIKE.object }); assert.equal(result.complete, true); assert.deepEqual( result.diagnostics.map(({ reason, severity }) => ({ reason, severity })), @@ -3275,7 +3283,7 @@ describe("validateDocument() supported document checks", () => { it("does not evaluate getters in a non-JSON input", () => { let evaluated = false; const input = { - ...like, + ...LIKE, get object() { evaluated = true; throw new Error("Do not invoke input accessors"); @@ -3288,11 +3296,11 @@ describe("validateDocument() supported document checks", () => { }); it("keeps relative references separate from malformed references", () => { - const relative = validateDocument({ ...like, object: "/posts/42" }); + const relative = validateDocument({ ...LIKE, object: "/posts/42" }); assert.equal(relative.complete, false); assert.equal(relative.diagnostics[0]?.reason, "unsupported_value_form"); const malformed = validateDocument({ - ...like, + ...LIKE, object: "https://example.com/not valid", }); assert.equal(malformed.complete, true); @@ -3307,7 +3315,7 @@ describe("validateDocument() supported document checks", () => { index, ]), ); - const input = { ...like, ...properties }; + const input = { ...LIKE, ...properties }; const result = validateDocument(input); assert.equal(result.complete, count <= 100); assert.equal(result.diagnostics.length, Math.min(count, 100)); @@ -3339,7 +3347,7 @@ describe("validateDocument() supported document checks", () => { it("distinguishes complete vocabulary errors from diagnostic truncation at the boundary", () => { for (const count of [99, 100, 101]) { const result = validateDocument({ - ...like, + ...LIKE, actor: Array(count).fill(false), }); assert.equal(result.complete, count <= 100); @@ -3362,7 +3370,7 @@ describe("validateDocument() supported document checks", () => { it("allows an omission preview with exactly 100 warnings but withholds a truncated one", () => { for (const count of [99, 100, 101]) { const input = { - ...like, + ...LIKE, object: Array.from({ length: count }, () => ({ type: "Note", content: [], @@ -3396,26 +3404,17 @@ describe("validateDocument() supported document checks", () => { }); }); -const creationOptions: DocumentPreparationOptions = { - authority: [{ allowed: true, path: [] }], - serverOwnedFields: [ - { path: ["id"], value: "https://example.com/objects/new" }, - { path: ["attributedTo"], value: like.actor }, - { path: ["published"], value: "2026-10-27T12:00:00Z" }, - ], -}; - describe("prepareDocument() trusted creation inputs", () => { it("keeps path-specific proposals independent for shared source objects", () => { + const FIRST = "https://example.com/objects/first"; + const SECOND = "https://example.com/objects/second"; const shared = { type: "Note" }; - const input = { ...like, type: "Create", object: [shared, shared] }; - const first = "https://example.com/objects/first"; - const second = "https://example.com/objects/second"; + const input = { ...LIKE, type: "Create", object: [shared, shared] }; const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], serverOwnedFields: [ - { path: ["object", 0, "id"], value: first }, - { path: ["object", 1, "id"], value: second }, + { path: ["object", 0, "id"], value: FIRST }, + { path: ["object", 1, "id"], value: SECOND }, ], }); assert.equal(result.complete, true); @@ -3423,15 +3422,15 @@ describe("prepareDocument() trusted creation inputs", () => { assert.deepEqual(result.normalizedDocument, { ...input, object: [ - { type: "Note", id: first }, - { type: "Note", id: second }, + { type: "Note", id: FIRST }, + { type: "Note", id: SECOND }, ], }); assert.deepEqual( result.changes.map((change) => "after" in change ? change.after : undefined, ), - [first, second], + [FIRST, SECOND], ); assert.deepEqual(shared, { type: "Note" }); }); @@ -3441,26 +3440,26 @@ describe("prepareDocument() trusted creation inputs", () => { throw new Error("Unexpected fetch"); }); const input = { - "@context": context, + "@context": CONTEXT, type: "Note", content: "Hello", to: [], debug: { values: [null, 1] }, }; const original = structuredClone(input); - const optionsBefore = structuredClone(creationOptions); - const result = prepareDocument(input, creationOptions); + const optionsBefore = structuredClone(CREATION_OPTIONS); + const result = prepareDocument(input, CREATION_OPTIONS); assert.equal(result.complete, true); assert.ok( result.diagnostics.every(({ severity }) => severity === "warning"), ); assert.deepEqual(result.normalizedDocument, { - "@context": context, + "@context": CONTEXT, type: "Note", content: "Hello", debug: input.debug, - id: creationOptions.serverOwnedFields[0]!.value, - attributedTo: like.actor, + id: CREATION_OPTIONS.serverOwnedFields[0]!.value, + attributedTo: LIKE.actor, published: "2026-10-27T12:00:00Z", }); assert.deepEqual( @@ -3474,27 +3473,27 @@ describe("prepareDocument() trusted creation inputs", () => { ); assert.notEqual(result.normalizedDocument!.debug, input.debug); assert.deepEqual(input, original); - assert.deepEqual(creationOptions, optionsBefore); + assert.deepEqual(CREATION_OPTIONS, optionsBefore); assert.equal(fetch.mock.callCount(), 0); }); it("preserves matching singleton representations and explicitly replaces null absence", () => { const input = { - ...like, - id: creationOptions.serverOwnedFields[0]!.value, - attributedTo: [null, like.actor], + ...LIKE, + id: CREATION_OPTIONS.serverOwnedFields[0]!.value, + attributedTo: [null, LIKE.actor], published: "2026-10-27T12:00:00Z", }; - assert.deepEqual(prepareDocument(input, creationOptions), { + assert.deepEqual(prepareDocument(input, CREATION_OPTIONS), { complete: true, diagnostics: [], changes: [], }); const absent = prepareDocument( - { ...like, actor: null }, + { ...LIKE, actor: null }, { - ...creationOptions, - serverOwnedFields: [{ path: ["actor"], value: like.actor }], + ...CREATION_OPTIONS, + serverOwnedFields: [{ path: ["actor"], value: LIKE.actor }], }, ); assert.equal(absent.changes[0]?.operation, "replace"); @@ -3504,15 +3503,15 @@ describe("prepareDocument() trusted creation inputs", () => { : undefined, null, ); - assert.equal(absent.normalizedDocument!.actor, like.actor); + assert.equal(absent.normalizedDocument!.actor, LIKE.actor); }); it("identifies original list entries in server-owned field conflicts", () => { - const input = { ...like, actor: [null, "https://other.example/actor"] }; + const input = { ...LIKE, actor: [null, "https://other.example/actor"] }; const original = structuredClone(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["actor"], value: like.actor }], + serverOwnedFields: [{ path: ["actor"], value: LIKE.actor }], }); assert.deepEqual( result.diagnostics.map(({ reason, path }) => ({ reason, path })), @@ -3525,7 +3524,7 @@ describe("prepareDocument() trusted creation inputs", () => { it("replaces the requested present alias and falls back to an existing absent alias", () => { const declaration = [ - context, + CONTEXT, { performer: { "@id": "as:actor", "@type": "@id" } }, ]; for (const [fields, requested, replaced] of [ @@ -3536,7 +3535,7 @@ describe("prepareDocument() trusted creation inputs", () => { const original = structuredClone(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: [requested], value: like.actor }], + serverOwnedFields: [{ path: [requested], value: LIKE.actor }], }); assert.deepEqual(result.diagnostics, []); assert.deepEqual( @@ -3545,7 +3544,7 @@ describe("prepareDocument() trusted creation inputs", () => { ); assert.deepEqual(result.normalizedDocument, { ...input, - [replaced]: like.actor, + [replaced]: LIKE.actor, }); assert.deepEqual(input, original); } @@ -3553,7 +3552,7 @@ describe("prepareDocument() trusted creation inputs", () => { it("prepares server-owned identifiers from null and null-only arrays", () => { for (const value of [null, [], [null], [null, null]]) { - const input = { ...like, id: value }; + const input = { ...LIKE, id: value }; const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], serverOwnedFields: [ @@ -3594,7 +3593,7 @@ describe("prepareDocument() trusted creation inputs", () => { ["id", "https://example.com/owned"], ["published", "2024-01-01T12:34:00Z"], ] as const) { - const input = { "@context": context, type: "Note", [property]: [] }; + const input = { "@context": CONTEXT, type: "Note", [property]: [] }; const original = structuredClone(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], @@ -3632,7 +3631,7 @@ describe("prepareDocument() trusted creation inputs", () => { ? "2024-01-01T12:34:00Z" : "https://example.com/owned"; const input = { - "@context": context, + "@context": CONTEXT, type: "Create", [property]: [], to: [], @@ -3651,7 +3650,7 @@ describe("prepareDocument() trusted creation inputs", () => { ], ); assert.deepEqual(result.normalizedDocument, { - "@context": context, + "@context": CONTEXT, type: "Create", [property]: value, }); @@ -3670,7 +3669,7 @@ describe("prepareDocument() trusted creation inputs", () => { it("supersedes omissions at the actual nested source alias", () => { const input = { "@context": [ - context, + CONTEXT, { performer: { "@id": "as:actor", "@type": "@id" } }, ], type: "Create", @@ -3679,7 +3678,7 @@ describe("prepareDocument() trusted creation inputs", () => { const original = structuredClone(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["object", 0, "actor"], value: like.actor }], + serverOwnedFields: [{ path: ["object", 0, "actor"], value: LIKE.actor }], }); assert.equal(result.complete, true); assert.deepEqual( @@ -3691,7 +3690,7 @@ describe("prepareDocument() trusted creation inputs", () => { ); assert.deepEqual(result.normalizedDocument, { ...input, - object: [{ type: "Like", performer: like.actor }], + object: [{ type: "Like", performer: LIKE.actor }], }); assert.match( result.diagnostics.find(({ path }) => path.at(-1) === "performer")! @@ -3702,16 +3701,16 @@ describe("prepareDocument() trusted creation inputs", () => { }); it("rejects conflicting identities and timestamps without changing their values", () => { - for (const field of creationOptions.serverOwnedFields) { + for (const field of CREATION_OPTIONS.serverOwnedFields) { const key = field.path[0] as string; const value = key === "published" ? "2025-01-01T00:00:00Z" : "https://other.example/value"; - const input = { ...like, [key]: value }; + const input = { ...LIKE, [key]: value }; const original = structuredClone(input); const result = prepareDocument(input, { - ...creationOptions, + ...CREATION_OPTIONS, serverOwnedFields: [field], }); assert.equal(result.complete, true); @@ -3743,7 +3742,7 @@ describe("prepareDocument() trusted creation inputs", () => { serverOwnedFields: [], }; const input = { - ...like, + ...LIKE, object: [{ type: "Follow", actor: false }], authority: "allowed", }; @@ -3764,15 +3763,15 @@ describe("prepareDocument() trusted creation inputs", () => { ); assert.equal(result.normalizedDocument, undefined); assert.deepEqual(result.changes, []); - const malformed = prepareDocument({ ...like, id: 123 }, creationOptions); + const malformed = prepareDocument({ ...LIKE, id: 123 }, CREATION_OPTIONS); assert.deepEqual( malformed.diagnostics.map(({ reason }) => reason), ["invalid_value_shape"], ); assert.throws( () => - prepareDocument(like, { - ...creationOptions, + prepareDocument(LIKE, { + ...CREATION_OPTIONS, authority: [], } as unknown as DocumentPreparationOptions), TypeError, @@ -3782,7 +3781,7 @@ describe("prepareDocument() trusted creation inputs", () => { it("uses original nested aliases for conflicts and generated previews", () => { const input = { "@context": [ - context, + CONTEXT, { created: { "@id": "as:published", "@type": "xsd:dateTime" }, author: { "@id": "as:attributedTo", "@type": "@id" }, @@ -3791,7 +3790,7 @@ describe("prepareDocument() trusted creation inputs", () => { type: "Create", object: [ null, - { type: "Note", created: null, author: [null, like.actor] }, + { type: "Note", created: null, author: [null, LIKE.actor] }, ], }; const options: DocumentPreparationOptions = { @@ -3801,7 +3800,7 @@ describe("prepareDocument() trusted creation inputs", () => { ], serverOwnedFields: [ { path: ["object", 1, "published"], value: "2026-10-27T12:00:00Z" }, - { path: ["object", 1, "attributedTo"], value: like.actor }, + { path: ["object", 1, "attributedTo"], value: LIKE.actor }, ], }; const original = structuredClone(input); @@ -3817,7 +3816,7 @@ describe("prepareDocument() trusted creation inputs", () => { { type: "Note", created: "2026-10-27T12:00:00Z", - author: [null, like.actor], + author: [null, LIKE.actor], }, ], }); @@ -3847,20 +3846,21 @@ describe("prepareDocument() trusted creation inputs", () => { [{ path: ["id"], value: "relative" }], [{ path: ["published"], value: "not-a-date" }], [{ path: ["content"], value: "server text" }], - [{ path: ["object", "id"], value: like.object }], - [{ path: ["debug", "id"], value: like.object }], + [{ path: ["object", "id"], value: LIKE.object }], + [{ path: ["debug", "id"], value: LIKE.object }], [ - { path: ["id"], value: like.object }, - { path: ["identity"], value: like.object }, + { path: ["id"], value: LIKE.object }, + { path: ["identity"], value: LIKE.object }, ], ]) { const input = { - ...like, - "@context": [context, { identity: "@id" }], + ...LIKE, + "@context": [CONTEXT, { identity: "@id" }], debug: { type: "Note" }, }; assert.throws( - () => prepareDocument(input, { ...creationOptions, serverOwnedFields }), + () => + prepareDocument(input, { ...CREATION_OPTIONS, serverOwnedFields }), TypeError, ); } @@ -3870,19 +3870,19 @@ describe("prepareDocument() trusted creation inputs", () => { for (const content of ["Hello", 123]) { for (const allowed of [true, false]) { const input = { - "@context": [context, { identity: "@id" }], + "@context": [CONTEXT, { identity: "@id" }], type: "Note", content, }; const original = structuredClone(input); for (const serverOwnedFields of [ - [{ path: ["missing", "id"], value: like.object }], + [{ path: ["missing", "id"], value: LIKE.object }], [{ path: ["content"], value: "server text" }], [{ path: ["id"], value: "relative" }], [{ path: ["published"], value: "not-a-date" }], [ - { path: ["id"], value: like.object }, - { path: ["identity"], value: like.object }, + { path: ["id"], value: LIKE.object }, + { path: ["identity"], value: LIKE.object }, ], ]) { assert.throws( @@ -3911,7 +3911,7 @@ describe("prepareDocument() trusted creation inputs", () => { () => prepareDocument(input, { authority: [{ allowed, path: [] }], - serverOwnedFields: [{ path, value: like.object }], + serverOwnedFields: [{ path, value: LIKE.object }], }), { name: "TypeError", @@ -3923,7 +3923,7 @@ describe("prepareDocument() trusted creation inputs", () => { assert.deepEqual( prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["id"], value: like.object }], + serverOwnedFields: [{ path: ["id"], value: LIKE.object }], }), validateDocument(input), ); @@ -3934,16 +3934,16 @@ describe("prepareDocument() trusted creation inputs", () => { for (const input of [ null, 123, - { "@context": context, type: 123 }, - { "@context": context, type: "Unknown" }, + { "@context": CONTEXT, type: 123 }, + { "@context": CONTEXT, type: "Unknown" }, { "@context": 123, type: "Note" }, { "@context": "https://unknown.example/context", type: "Note" }, - { "@context": context }, + { "@context": CONTEXT }, ]) { const expected = validateDocument(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["id"], value: like.object }], + serverOwnedFields: [{ path: ["id"], value: LIKE.object }], }); assert.deepEqual(result, expected); assert.ok( @@ -3955,11 +3955,11 @@ describe("prepareDocument() trusted creation inputs", () => { }); it("distinguishes a blocked nested target from a definitely missing parent", () => { - const input = { ...like, object: [{ type: "Unknown" }] }; + const input = { ...LIKE, object: [{ type: "Unknown" }] }; assert.deepEqual( prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["object", 0, "id"], value: like.object }], + serverOwnedFields: [{ path: ["object", 0, "id"], value: LIKE.object }], }), validateDocument(input), ); @@ -3971,7 +3971,7 @@ describe("prepareDocument() trusted creation inputs", () => { () => prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path, value: like.object }], + serverOwnedFields: [{ path, value: LIKE.object }], }), TypeError, ); @@ -3984,13 +3984,13 @@ describe("prepareDocument() trusted creation inputs", () => { for (let index = 0; index < depth; index += 1) { input = { type: "Create", object: input }; } - input["@context"] = context; + input["@context"] = CONTEXT; input.to = []; const original = structuredClone(input); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], serverOwnedFields: [ - { path: [...Array(depth).fill("object"), "id"], value: like.object }, + { path: [...Array(depth).fill("object"), "id"], value: LIKE.object }, ], }); assert.equal(result.complete, false); @@ -4013,7 +4013,7 @@ describe("prepareDocument() trusted creation inputs", () => { it("does not invoke getters while checking blocked preparation targets", () => { let evaluated = false; - const input = { ...like }; + const input = { ...LIKE }; Object.defineProperty(input, "object", { enumerable: true, get() { @@ -4023,7 +4023,7 @@ describe("prepareDocument() trusted creation inputs", () => { }); const result = prepareDocument(input, { authority: [{ allowed: true, path: [] }], - serverOwnedFields: [{ path: ["object", "id"], value: like.object }], + serverOwnedFields: [{ path: ["object", "id"], value: LIKE.object }], }); assert.equal(evaluated, false); assert.ok( @@ -4034,11 +4034,11 @@ describe("prepareDocument() trusted creation inputs", () => { }); it("preserves authority paths as diagnostic locations without requiring document targets", () => { - const input = { "@context": context, type: "Note", content: 123 }; + const input = { "@context": CONTEXT, type: "Note", content: 123 }; const path = ["externalIdentity", 5, "actor"]; const result = prepareDocument(input, { authority: [{ allowed: false, path }], - serverOwnedFields: [{ path: ["id"], value: like.object }], + serverOwnedFields: [{ path: ["id"], value: LIKE.object }], }); assert.deepEqual( result.diagnostics.map(({ reason, path: location }) => ({ @@ -4056,21 +4056,21 @@ describe("prepareDocument() trusted creation inputs", () => { it("revalidates generated fields and keeps incomplete documents without a preview", () => { const link = prepareDocument( - { "@context": context, type: "Link" }, + { "@context": CONTEXT, type: "Link" }, { - ...creationOptions, - serverOwnedFields: [{ path: ["actor"], value: like.actor }], + ...CREATION_OPTIONS, + serverOwnedFields: [{ path: ["actor"], value: LIKE.actor }], }, ); assert.equal(link.complete, false); assert.equal(link.diagnostics[0]?.reason, "unsupported_property"); assert.equal(link.normalizedDocument, undefined); for (const input of [ - { ...like, type: "Unknown" }, - { ...like, "@context": "https://unknown.example/context" }, - { ...like, duration: "PT1H" }, + { ...LIKE, type: "Unknown" }, + { ...LIKE, "@context": "https://unknown.example/context" }, + { ...LIKE, duration: "PT1H" }, ]) { - const result = prepareDocument(input, creationOptions); + const result = prepareDocument(input, CREATION_OPTIONS); assert.equal(result.complete, false); assert.equal(result.normalizedDocument, undefined); assert.deepEqual(result.changes, []); @@ -4082,7 +4082,7 @@ describe("prepareDocument() trusted creation inputs", () => { allowed: false, path: [], })); - const result = prepareDocument(like, { + const result = prepareDocument(LIKE, { authority: authority as unknown as DocumentPreparationOptions["authority"], serverOwnedFields: [], @@ -4091,7 +4091,7 @@ describe("prepareDocument() trusted creation inputs", () => { assert.equal(result.diagnostics.length, 100); assert.equal(result.diagnostics.at(-1)?.reason, "diagnostic_limit_reached"); assert.equal(result.normalizedDocument, undefined); - assert.deepEqual(validateDocument(like), { + assert.deepEqual(validateDocument(LIKE), { complete: true, diagnostics: [], changes: [], @@ -4103,11 +4103,11 @@ describe("describeDocument() semantic view", () => { it("names standard properties of every embedded document, whatever their spelling", () => { const input = { "@context": [ - context, + CONTEXT, { - v: { "@id": `${context}#`, "@prefix": true }, + v: { "@id": `${CONTEXT}#`, "@prefix": true }, by: { "@id": "as:actor", "@type": "@id" }, - payload: { "@id": `${context}#object`, "@type": "@id" }, + payload: { "@id": `${CONTEXT}#object`, "@type": "@id" }, identifier: "@id", recipients: { "@id": "as:to", "@type": "@id" }, }, @@ -4122,7 +4122,7 @@ describe("describeDocument() semantic view", () => { { type: "Like", actor: "https://example.com/alice", - object: like.object, + object: LIKE.object, }, ], custom: { type: "Note", actor: "https://example.com/mallory" }, @@ -4141,13 +4141,13 @@ describe("describeDocument() semantic view", () => { { path: [], property: undefined, - type: `${context}#Undo`, + type: `${CONTEXT}#Undo`, kind: "activity", }, { path: ["payload", 1], property: "object", - type: `${context}#Like`, + type: `${CONTEXT}#Like`, kind: "activity", }, ], @@ -4170,7 +4170,7 @@ describe("describeDocument() semantic view", () => { it("classifies actors, collections, links, and objects", () => { const nodes = describeDocument({ - "@context": context, + "@context": CONTEXT, type: "Add", actor: { type: "Person", id: "https://example.com/alice" }, object: { @@ -4196,7 +4196,7 @@ describe("describeDocument() semantic view", () => { it("expands compact IRI values through prefixes, as Fedify reads them", () => { const nodes = describeDocument({ "@context": [ - context, + CONTEXT, { local: { "@id": "https://other.example/users/", "@prefix": true } }, ], type: "Create", @@ -4223,7 +4223,7 @@ describe("describeDocument() semantic view", () => { property: "to", path: ["to", 0], value: "as:Public", - iri: `${context}#Public`, + iri: `${CONTEXT}#Public`, }, { property: "to", @@ -4243,10 +4243,10 @@ describe("describeDocument() semantic view", () => { it("leaves values unexpanded through terms that are not prefixes", () => { const nodes = describeDocument({ - "@context": [context, { x: { "@id": "https://own.example/users/" } }], + "@context": [CONTEXT, { x: { "@id": "https://own.example/users/" } }], type: "Like", actor: "x:owner", - object: like.object, + object: LIKE.object, }); assert.ok(nodes != null); assert.deepEqual(nodes[0]!.identifiers[0], { @@ -4257,11 +4257,328 @@ describe("describeDocument() semantic view", () => { }); }); + it("describes node objects in extension values with the context they inherit", () => { + const nodes = describeDocument({ + "@context": [ + CONTEXT, + { + ex: "https://ext.example/ns#", + hidden: { "@id": "as:bcc", "@type": "@id" }, + }, + ], + type: "Like", + actor: "https://example.com/alice", + object: LIKE.object, + "ex:quoted": { + type: "Note", + to: ["https://example.com/bob", { id: "as:Public" }], + hidden: "https://example.com/blind", + "ex:literal": { "@type": "@json", "@value": { bcc: "x" } }, + "ex:value": { "@value": "v" }, + }, + "ex:scoped": [ + { "@context": { secret: "as:bcc" }, secret: "x", inner: { bcc: "y" } }, + ], + }); + assert.ok(nodes != null); + assert.equal(nodes.length, 1); + const { extensions } = nodes[0]!; + assert.deepEqual( + extensions.map(({ path, property, uninterpreted }) => ({ + path, + property, + uninterpreted, + })), + [ + { path: ["ex:quoted"], property: undefined, uninterpreted: false }, + { path: ["ex:quoted", "to", 1], property: "to", uninterpreted: false }, + { path: ["ex:scoped", 0], property: undefined, uninterpreted: true }, + { + path: ["ex:scoped", 0, "inner"], + property: undefined, + uninterpreted: true, + }, + ], + ); + assert.deepEqual(Object.fromEntries(extensions[0]!.properties), { + bcc: ["hidden"], + to: ["to"], + type: ["type"], + }); + assert.deepEqual( + extensions[0]!.identifiers.map(({ property, iri }) => ({ + property, + iri, + })), + [ + { property: "bcc", iri: "https://example.com/blind" }, + { property: "to", iri: "https://example.com/bob" }, + ], + ); + assert.deepEqual(extensions[1]!.identifiers, [ + { + property: "id", + path: ["ex:quoted", "to", 1, "id"], + value: "as:Public", + iri: `${CONTEXT}#Public`, + }, + ]); + assert.deepEqual(Object.fromEntries(extensions[3]!.properties), {}); + }); + + it("reads extension node objects through containers, sets and keyword aliases", () => { + const nodes = describeDocument({ + "@context": [ + CONTEXT, + { + ex: "https://ext.example/ns#", + listed: { "@id": "as:bcc", "@type": "@id", "@container": "@list" }, + }, + ], + type: "Like", + actor: "https://example.com/alice", + object: LIKE.object, + "ex:quoted": { + type: "Delete", + listed: ["https://example.com/blind"], + to: { + "@set": [ + "https://example.com/carol", + { id: "https://example.com/dave" }, + ], + }, + "ex:literal": { type: "@json", "@value": { bcc: "x" } }, + }, + "ex:valued": { to: { "@value": "https://example.com/erin" } }, + }); + assert.ok(nodes != null); + const [quoted, dave, valued] = nodes[0]!.extensions; + assert.equal(nodes[0]!.extensions.length, 3); + assert.deepEqual(quoted!.types, [`${CONTEXT}#Delete`]); + assert.deepEqual(Object.fromEntries(quoted!.properties), { + bcc: ["listed"], + to: ["to"], + type: ["type"], + }); + assert.deepEqual( + quoted!.identifiers.map(({ property, path }) => ({ property, path })), + [ + { property: "bcc", path: ["ex:quoted", "listed", 0] }, + { property: "to", path: ["ex:quoted", "to", "@set", 0] }, + ], + ); + assert.equal(quoted!.uninterpreted, false); + assert.deepEqual(dave!.path, ["ex:quoted", "to", "@set", 1]); + assert.equal(dave!.property, "to"); + assert.deepEqual(dave!.parent, ["ex:quoted"]); + // A literal is no reference the checker can read. + assert.deepEqual(valued!.path, ["ex:valued"]); + assert.equal(valued!.uninterpreted, true); + }); + + const quotedNode = (quoted: unknown) => + describeDocument({ + ...LIKE, + "@context": [ + CONTEXT, + { + ex: "https://ext.example/ns#", + hint: "as:to", + toMap: { "@id": "as:to", "@container": "@language" }, + labels: { "@id": "ex:labels", "@container": "@language" }, + _: `${CONTEXT}#`, + }, + ], + "ex:quoted": quoted, + })?.[0]?.extensions[0]; + + it("leaves extension node objects uninterpreted where references are unread", () => { + for (const quoted of [ + { type: "Note", hint: "as:Public" }, + { type: "Note", [`${CONTEXT}#to`]: "as:Public" }, + { type: "Note", id: "/notes/1" }, + { + type: "Note", + to: { "@context": { as: "https://a.example/" }, "@set": ["as:Public"] }, + }, + { type: "Note", toMap: { id: "as:Public" } }, + { type: "Link", href: ["https://a.example/1", "https://a.example/2"] }, + { type: "Note", id: { id: "https://a.example/1" } }, + // As a reference, a blank node may name a node object defined apart. + { type: "Note", to: "_:Public" }, + { type: "Note", attributedTo: "_:author" }, + // A value or a list with properties is no valid JSON-LD, nor is a + // value that is an object but no JSON literal. + { "@set": [], bcc: "https://a.example/b" }, + { "@value": { bcc: "https://a.example/b" } }, + { type: "Note", "@reverse": { attributedTo: { type: "Note" } } }, + { type: "Note", "@nest": { to: "as:Public" } }, + ]) { + assert.equal( + quotedNode(quoted)?.uninterpreted, + true, + JSON.stringify(quoted), + ); + } + // A node object whose id is used elsewhere is not read apart from it. + const joined = describeDocument({ + ...LIKE, + "@context": [CONTEXT, { ex: "https://ext.example/ns#" }], + "ex:quoted": [ + { type: "Note", attributedTo: { id: "_:author" } }, + { id: "_:author", type: "Link", href: "https://a.example/victim" }, + { id: LIKE.object, type: "Note" }, + { id: "https://a.example/apart", type: "Note" }, + // Its own properties name no other node, but those it holds do. + { + id: "https://a.example/self", + type: "Link", + href: "https://a.example/self", + }, + { + id: "https://a.example/held", + type: "Note", + attributedTo: { type: "Link", href: "https://a.example/held" }, + }, + ], + })?.[0]?.extensions; + assert.deepEqual( + joined?.map(({ path, uninterpreted }) => ({ path, uninterpreted })), + [ + { path: ["ex:quoted", 0], uninterpreted: false }, + { path: ["ex:quoted", 0, "attributedTo"], uninterpreted: true }, + { path: ["ex:quoted", 1], uninterpreted: true }, + { path: ["ex:quoted", 2], uninterpreted: true }, + { path: ["ex:quoted", 3], uninterpreted: false }, + { path: ["ex:quoted", 4], uninterpreted: false }, + { path: ["ex:quoted", 5], uninterpreted: true }, + { path: ["ex:quoted", 5, "attributedTo"], uninterpreted: false }, + ], + ); + // A value whose keys a context of its own may alias cannot be read, but + // its value holds no node objects. + const aliased = describeDocument({ + ...LIKE, + "@context": [CONTEXT, { ex: "https://ext.example/ns#" }], + "ex:quoted": { + "@context": { kind: "@type" }, + kind: "@json", + "@value": { bcc: "x" }, + }, + })?.[0]?.extensions; + assert.deepEqual( + aliased?.map(({ path, uninterpreted }) => ({ path, uninterpreted })), + [{ path: ["ex:quoted"], uninterpreted: true }], + ); + }); + + it("reads the kinds, blank node ids and language maps of extension node objects", () => { + const link = quotedNode({ + type: "Link", + id: "https://example.com/links/1", + href: "https://example.com/bob", + }); + assert.equal(link?.uninterpreted, false); + assert.equal(link?.kind, "link"); + assert.equal(quotedNode({ type: "Note", content: "x" })?.kind, "object"); + // A blank node identifier as an id names no resource, and stays + // readable, but is no compact IRI, whatever prefix "_" names. + const blank = quotedNode({ type: "Note", id: "_:b0", to: "as:Public" }); + assert.equal(blank?.uninterpreted, false); + assert.deepEqual( + blank?.identifiers.map(({ iri }) => iri), + ["_:b0", `${CONTEXT}#Public`], + ); + assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); + assert.equal( + describeDocument({ + ...LIKE, + "@context": [CONTEXT, { _: `${CONTEXT}#` }], + type: "_:Like", + }), + null, + ); + // A language map holds strings, whatever its language tags. + const labelled = quotedNode({ type: "Note", labels: { id: "Halo" } }); + assert.equal(labelled?.uninterpreted, false); + const labels = describeDocument({ + ...LIKE, + "@context": [ + CONTEXT, + { + labels: { + "@id": "https://example.com/labels", + "@container": "@language", + }, + }, + ], + labels: { id: "Halo", bcc: "x" }, + }); + assert.deepEqual(labels?.[0]?.extensions, []); + // A key with a @language container holds node objects in a list. + const listedLabels = quotedNode({ + type: "Note", + labels: [{ type: "Note", bcc: "https://a.example/b" }], + }); + assert.equal(listedLabels?.uninterpreted, false); + const listedNode = describeDocument({ + ...LIKE, + "@context": [ + CONTEXT, + { + labels: { + "@id": "https://example.com/labels", + "@container": "@language", + }, + }, + ], + labels: [{ type: "Note", bcc: "https://a.example/b" }], + })?.[0]?.extensions; + assert.deepEqual( + listedNode?.map(({ path, uninterpreted, properties }) => ({ + path, + uninterpreted, + bcc: properties.get("bcc"), + })), + [{ path: ["labels", 0], uninterpreted: false, bcc: ["bcc"] }], + ); + // An object in a language map is no valid JSON-LD, and is not read. + const labelled2 = describeDocument({ + ...LIKE, + "@context": [ + CONTEXT, + { + labels: { + "@id": "https://example.com/labels", + "@container": "@language", + }, + }, + ], + labels: { en: { bcc: "x" }, ko: ["x", { bcc: "y" }] }, + }); + assert.deepEqual( + labelled2?.[0]?.extensions.map(({ path, uninterpreted }) => ({ + path, + uninterpreted, + })), + [ + { path: ["labels", "en"], uninterpreted: true }, + { path: ["labels", "ko", 1], uninterpreted: true }, + ], + ); + // A JSON literal holds data, however its keywords are spelled. + const literal = describeDocument({ + ...LIKE, + custom: { type: "@json", "@value": { "@id": 1, bcc: "x" } }, + }); + assert.deepEqual(literal?.[0]?.extensions, []); + }); + it("returns null unless validation completes without errors", () => { - assert.equal(describeDocument({ ...like, actor: false }), null); - assert.equal(describeDocument({ ...like, type: "Place" }), null); + assert.equal(describeDocument({ ...LIKE, actor: false }), null); + assert.equal(describeDocument({ ...LIKE, type: "Place" }), null); assert.equal(describeDocument("not a document"), null); - assert.ok(describeDocument({ ...like, custom: [] }) != null); + assert.ok(describeDocument({ ...LIKE, custom: [] }) != null); }); it("returns null for sparse arrays and accessors without evaluating them", () => { diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 43d69a5..7d37fc1 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -78,7 +78,7 @@ export type DiagnosticCategory = export type DiagnosticSeverity = "error" | "warning"; /** Stable classifications; human-readable diagnostic messages may change. */ -export const diagnosticReasons = { +export const DIAGNOSTIC_REASONS = { invalid_document_root: { category: "vocabulary", severity: "error" }, non_json_value: { category: "vocabulary", severity: "error" }, invalid_value_shape: { category: "vocabulary", severity: "error" }, @@ -114,7 +114,7 @@ export const diagnosticReasons = { } >; -export type DiagnosticReason = keyof typeof diagnosticReasons; +export type DiagnosticReason = keyof typeof DIAGNOSTIC_REASONS; export interface Diagnostic { readonly reason: DiagnosticReason; @@ -186,12 +186,12 @@ export interface DocumentPreparationOptions { readonly serverOwnedFields: readonly ServerOwnedField[]; } -const activityStreamsContext = "https://www.w3.org/ns/activitystreams"; -const maximumDiagnostics = 100; -const maximumPathDepth = 64; -const actorTypes = [Application, Group, Organization, Person, Service]; +const ACTIVITY_STREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams"; +const MAXIMUM_DIAGNOSTICS = 100; +const MAXIMUM_PATH_DEPTH = 64; +const ACTOR_TYPES = [Application, Group, Organization, Person, Service]; /** The ActivityStreams properties that address a document's recipients. */ -export const addressingProperties = [ +export const ADDRESSING_PROPERTIES = [ "to", "cc", "bto", @@ -199,7 +199,7 @@ export const addressingProperties = [ "audience", ] as const; // AS2 vocabulary Object properties that do not also apply to Link. -const objectOnlyProperties: ReadonlySet = new Set([ +const OBJECT_ONLY_PROPERTIES: ReadonlySet = new Set([ "attachment", "attributedTo", "audience", @@ -236,7 +236,7 @@ type Report = ( type VocabularyType = $EntityType | typeof Link | typeof Mention; // Unsupported custom types by Fedify are not in this checker's coverage. -const implementedTypes: ReadonlySet = new Set([ +const IMPLEMENTED_TYPES: ReadonlySet = new Set([ Note, APObject, Article, @@ -283,6 +283,44 @@ const implementedTypes: ReadonlySet = new Set([ View, ]); +// Objects with these keywords are values or lists, not JSON-LD node objects. +const VALUE_OBJECT_KEYWORDS: ReadonlySet = new Set([ + "@value", + "@list", + "@set", +]); + +const IDENTIFIER_PROPERTIES: ReadonlySet = new Set([ + "id", + "actor", + "object", + "target", + "origin", + "result", + "instrument", + "attributedTo", + "location", + "preview", + ...ADDRESSING_PROPERTIES, + "items", + "orderedItems", + "current", + "first", + "last", + "partOf", + "next", + "prev", + "inbox", + "outbox", + "following", + "followers", + "liked", + "href", +]); + +// A node object has one of each. +const FUNCTIONAL_REFERENCES = ["id", "href"] as const; + // Owns diagnostic collection and completion state for one validation call. function createReporter() { const diagnostics: Diagnostic[] = []; @@ -291,18 +329,18 @@ function createReporter() { const report: Report = (reason, path, message) => { if (truncated) return; - if (diagnostics.length === maximumDiagnostics) { + if (diagnostics.length === MAXIMUM_DIAGNOSTICS) { complete = false; truncated = true; - diagnostics[maximumDiagnostics - 1] = { + diagnostics[MAXIMUM_DIAGNOSTICS - 1] = { reason: "diagnostic_limit_reached", - ...diagnosticReasons.diagnostic_limit_reached, + ...DIAGNOSTIC_REASONS.diagnostic_limit_reached, path: [], message: "Further diagnostics were omitted.", }; return; } - const classification = diagnosticReasons[reason]; + const classification = DIAGNOSTIC_REASONS[reason]; if (classification.category === "support") complete = false; diagnostics.push({ reason, ...classification, path, message }); }; @@ -583,7 +621,8 @@ function resolveTerm( const term = terms.get(value); if (term != null) return term.iri; const colon = value.indexOf(":"); - if (colon < 0) return undefined; + // A blank node identifier is no IRI, whatever term "_" names. + if (colon < 0 || value.startsWith("_:")) return undefined; if (pending?.has(value.slice(0, colon))) return undefined; const prefix = terms.get(value.slice(0, colon)); if (prefix?.prefix) { @@ -839,7 +878,10 @@ function getContextTables(): ContextTables { if (contextTables != null) return contextTables; // Bundled availability is not blanket admission to this bounded profile. const bundledContexts = new Map>(); - for (const url of [activityStreamsContext, "https://w3id.org/security/v1"]) { + for (const url of [ + ACTIVITY_STREAMS_CONTEXT, + "https://w3id.org/security/v1", + ]) { const document = preloadedContexts[url] as { "@context": Record; }; @@ -854,7 +896,7 @@ function getContextTables(): ContextTables { ); bundledContexts.set(url, terms); } - const standardTerms = bundledContexts.get(activityStreamsContext)!; + const standardTerms = bundledContexts.get(ACTIVITY_STREAMS_CONTEXT)!; // Publish the cache only after all definitions have been interpreted. contextTables = { bundledContexts, @@ -874,7 +916,7 @@ function applyContextEntry( if (typeof value === "string") { const url = value === "http://www.w3.org/ns/activitystreams" - ? activityStreamsContext + ? ACTIVITY_STREAMS_CONTEXT : value; const bundled = bundledContexts.get(url); if (bundled == null) { @@ -1030,7 +1072,7 @@ function checkType( if (typeIri === Link.typeId.href) Type = Link; else if (typeIri === Mention.typeId.href) Type = Mention; } - if (Type == null || !implementedTypes.has(Type)) { + if (Type == null || !IMPLEMENTED_TYPES.has(Type)) { const reason = Array.isArray(value) ? "unsupported_value_form" : value == null || typeof value === "string" @@ -1074,7 +1116,7 @@ function getEmbeddedFedifyRange( property: string, collectionRange: readonly VocabularyType[] | undefined, ): readonly VocabularyType[] | undefined { - if (property === "actor" || property === "attributedTo") return actorTypes; + if (property === "actor" || property === "attributedTo") return ACTOR_TYPES; return collectionRange?.filter((Expected) => Expected !== Link); } @@ -1114,7 +1156,7 @@ function getCollectionRange( if (property === "partOf") return [Collection, Link]; if (["next", "prev"].includes(property)) return [CollectionPage, Link]; } - const actor = actorTypes.some((Actor) => isTypeOrSubtype(Type, Actor)); + const actor = ACTOR_TYPES.some((Actor) => isTypeOrSubtype(Type, Actor)); if (actor) { if (["inbox", "outbox"].includes(property)) { return [OrderedCollection, OrderedCollectionPage]; @@ -1419,7 +1461,7 @@ function getCommonPropertyKind( property: string, ): "text" | "languageMap" | "datetime" | "id" | "mediaType" | undefined { const object = isTypeOrSubtype(Type, APObject); - const actor = actorTypes.some((Actor) => isTypeOrSubtype(Type, Actor)); + const actor = ACTOR_TYPES.some((Actor) => isTypeOrSubtype(Type, Actor)); const textProperties = object ? ["name", "summary", "content"] : ["name"]; if (actor) textProperties.push("preferredUsername"); if (textProperties.includes(property)) return "text"; @@ -1581,6 +1623,113 @@ function checkLinkProperty( } } +// The keyword a key stands for: itself, or the keyword a term aliases. A +// context the checker does not read aliases nothing it knows of. +function keywordOf( + key: string, + context: DocumentContext | null | undefined, +): string | undefined { + if (key.startsWith("@")) return key; + const iri = context?.terms.get(key)?.iri; + return iri?.startsWith("@") === true ? iri : undefined; +} + +function isNodeObject( + values: Record, + context: DocumentContext | null, +): boolean { + return !Object.keys(values).some((key) => + VALUE_OBJECT_KEYWORDS.has(keywordOf(key, context) ?? ""), + ); +} + +// The context an extension object is described with as a node object: none +// for a value or a list, or null for one with properties as well, or for a +// value that is an object but no JSON literal, which are no valid JSON-LD +// and cannot be read. +function nodeContext( + values: Record, + context: DocumentContext | null | undefined, +): DocumentContext | null | undefined { + if (context === undefined || isNodeObject(values, context)) return context; + return Object.keys(values).some((key) => { + const keyword = keywordOf(key, context); + const value = values[key]; + // A value is a scalar unless it is a JSON literal. + return ( + keyword == null || + (keyword === "@value" && + value !== null && + typeof value === "object" && + !isJsonLiteral(values, context)) + ); + }) + ? null + : undefined; +} + +// Whether a value object holds a JSON literal, however its keywords are +// spelled. +function isJsonLiteral( + values: Record, + context: DocumentContext | null | undefined, +): boolean { + return Object.keys(values).some((key) => { + if (keywordOf(key, context) !== "@type") return false; + const type = values[key]; + // Read a list entry without evaluating an accessor. + return ( + type === "@json" || + (Array.isArray(type) && + type.length === 1 && + Object.getOwnPropertyDescriptor(type, 0)?.value === "@json") + ); + }); +} + +// The context a value of an extension object is described with: none for a +// context definition, for the value of a value object, which is a scalar or +// a JSON literal, or for a language map, none of which holds node objects. +// A key with a @language container holds a language map only as an object. +function extensionChildContext( + key: string, + value: unknown, + context: DocumentContext | null | undefined, +): DocumentContext | null | undefined { + return key === "@context" || + keywordOf(key, context) === "@value" || + (context?.terms.get(key)?.container === "@language" && isLanguageMap(value)) + ? undefined + : context; +} + +function isLanguageMap(value: unknown): boolean { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +// Check the keywords of an extension object, and tell whether it holds a +// JSON literal, however its keywords are spelled. +function checkJsonLiteral( + values: Record, + path: FieldPath, + report: Report, + context: DocumentContext | null | undefined, +): boolean { + const literal = checkExtensionKeywords(values, path, report); + return literal || isJsonLiteral(values, context); +} + +// The context an extension object is read with: none when it is not +// described, or null within a context of its own, which is not read. +function extensionContext( + values: Record, + context: DocumentContext | null | undefined, +): DocumentContext | null | undefined { + return context !== undefined && Object.hasOwn(values, "@context") + ? null + : context; +} + // Check explicit keyword shapes, without expanding contexts or keyword aliases. // Return whether @value contains literal JSON rather than nested JSON-LD. function checkExtensionKeywords( @@ -1637,15 +1786,17 @@ function checkExtensionKeywords( } function getInapplicableProperties(Type: VocabularyType): ReadonlySet { - return isTypeOrSubtype(Type, Link) ? objectOnlyProperties : new Set(); + return isTypeOrSubtype(Type, Link) + ? OBJECT_ONLY_PROPERTIES + : new Set(); } function checkPathDepth(path: FieldPath, report: Report): boolean { - if (path.length <= maximumPathDepth) return true; + if (path.length <= MAXIMUM_PATH_DEPTH) return true; report( "processing_limit_exceeded", path, - `Nesting exceeds the supported limit of ${maximumPathDepth} path segments.`, + `Nesting exceeds the supported limit of ${MAXIMUM_PATH_DEPTH} path segments.`, ); return false; } @@ -1663,6 +1814,11 @@ function checkDocumentValue( fields: readonly SourceProperty[], ) => void, onUnavailableDocument: (path: FieldPath) => void = () => undefined, + onExtensionNode?: ( + values: Record, + path: FieldPath, + context: DocumentContext | null, + ) => void, ): void { const { report, isFull } = reporter; const pending: (() => void)[] = []; @@ -1670,10 +1826,13 @@ function checkDocumentValue( // Check JSON safety and selected explicit keyword shapes. This is not full // JSON-LD validation; extension semantics and nested contexts remain unchecked. + // Given the context it inherits, report each node object outside JSON + // literals with that context, or with null within a context of its own. function checkExtension( value: unknown, path: FieldPath, keywords = true, + context?: DocumentContext | null, ): void { if (!checkPathDepth(path, report)) return; if (!isJsonValueShape(value)) { @@ -1690,17 +1849,29 @@ function checkDocumentValue( const children: (() => void)[] = []; if (Array.isArray(value)) { visitList(value, path, report, (entry, entryPath) => { - children.push(() => checkExtension(entry, entryPath, keywords)); + children.push(() => + checkExtension(entry, entryPath, keywords, context), + ); }); } else { const values = readDocument(value, path, report); if (values != null) { + const own = extensionContext(values, context); const jsonLiteral = - keywords && checkExtensionKeywords(values, path, report); + keywords && checkJsonLiteral(values, path, report, own); + const node = nodeContext(values, own); + if (keywords && node !== undefined) { + onExtensionNode?.(values, path, node); + } for (const key of Object.keys(values)) { const childKeywords = keywords && !(jsonLiteral && key === "@value"); children.push(() => - checkExtension(values[key], [...path, key], childKeywords), + checkExtension( + values[key], + [...path, key], + childKeywords, + extensionChildContext(key, values[key], own), + ), ); } } @@ -1780,7 +1951,7 @@ function checkDocumentValue( referenceProperties.push( "attributedTo", "location", - ...addressingProperties, + ...ADDRESSING_PROPERTIES, ); } if (isActivity) { @@ -1822,7 +1993,12 @@ function checkDocumentValue( fieldPath, "This custom property's data is preserved; its semantics were not checked.", ); - checkExtension(value, fieldPath); + checkExtension( + value, + fieldPath, + true, + extensionChildContext(source, value, context), + ); return; } report( @@ -2433,6 +2609,64 @@ export interface DocumentNode { * documents belong to those documents' nodes. */ readonly identifiers: readonly DocumentIdentifier[]; + /** + * The JSON-LD node objects inside this document's own opaque extension + * values, at any depth, in traversal order. + */ + readonly extensions: readonly ExtensionNode[]; +} + +/** + * A JSON-LD node object inside an opaque extension value. The checker does + * not read it as a vocabulary document, but a JSON-LD processor reads its + * keys with the context it inherits, so its standard properties are named + * as a described document's are, whatever their type coercion. + */ +export interface ExtensionNode { + /** Source path of the node object. */ + readonly path: FieldPath; + /** + * The standard name of the property that holds the node object in another + * one, whatever its source spelling; absent directly under an extension + * property. + */ + readonly property?: string; + /** The path of the node object whose `property` holds this one. */ + readonly parent?: FieldPath; + /** + * Whether the checker cannot read all of the node object: it, or one + * around it in the extension value, has a `@context` of its own, which the + * checker does not read, or a reference property holds something other + * than absolute IRIs, a blank node identifier as its `id`, and node + * objects, such as a value object, a language map, a string the + * property's alias does not coerce to an IRI, a relative reference or a + * blank node identifier, or it has more than one `id` or `href`, or a node + * object as one, or it nests properties with `@nest` or reverses them with + * `@reverse`, or its `id` is used elsewhere in the document, as the `id` of + * another node object or a reference outside its own properties, so that + * it is not read apart; a `Link` whose `href` is its own `id` is read. An + * uninterpreted node object has no properties, identifiers or types when + * its context is unread. + */ + readonly uninterpreted: boolean; + /** + * Standard property names mapped to the source keys that supply them, + * matched by IRI whatever their type coercion or container. + */ + readonly properties: ReadonlyMap; + /** + * String IRI references held by the node object's own standard + * properties, through arrays and `@set` or `@list` objects, with compact + * IRIs expanded through the context's prefixes. + */ + readonly identifiers: readonly DocumentIdentifier[]; + /** The type IRIs, with terms and compact IRIs expanded. */ + readonly types: readonly string[]; + /** + * The vocabulary category of its types the checker knows: `link` if one + * is a link type, or else that of the first. + */ + readonly kind?: DocumentNodeKind; } /** One IRI reference as written and as the context resolves it. */ @@ -2447,39 +2681,18 @@ export interface DocumentIdentifier { readonly iri: string; } -const identifierProperties: ReadonlySet = new Set([ - "id", - "actor", - "object", - "target", - "origin", - "result", - "instrument", - "attributedTo", - "location", - "preview", - ...addressingProperties, - "items", - "orderedItems", - "current", - "first", - "last", - "partOf", - "next", - "prev", - "inbox", - "outbox", - "following", - "followers", - "liked", - "href", -]); - // IRI values expand compact IRIs through prefixes only; JSON-LD applies terms // to property names and types, not to node identifiers. function expandIdentifier(value: string, context: DocumentContext): string { const colon = value.indexOf(":"); - if (colon <= 0 || value.startsWith("//", colon + 1)) return value; + // A blank node identifier is no compact IRI, whatever prefix "_" names. + if ( + colon <= 0 || + value.startsWith("//", colon + 1) || + value.startsWith("_:") + ) { + return value; + } const prefix = context.terms.get(value.slice(0, colon)); // Like resolveTerm(), only terms flagged as prefixes expand compact IRIs. if (prefix?.prefix !== true || prefix.iri.startsWith("@")) return value; @@ -2489,16 +2702,296 @@ function expandIdentifier(value: string, context: DocumentContext): string { function nodeKind(Type: VocabularyType): DocumentNodeKind { if (isTypeOrSubtype(Type, Link)) return "link"; if (isTypeOrSubtype(Type, Activity)) return "activity"; - if (actorTypes.some((Actor) => isTypeOrSubtype(Type, Actor))) return "actor"; + if (ACTOR_TYPES.some((Actor) => isTypeOrSubtype(Type, Actor))) return "actor"; if (isTypeOrSubtype(Type, Collection)) return "collection"; return "object"; } +// Name the standard properties of a document or node object, and the IRI +// references they hold, as read with the context it is interpreted with. +function describeProperties( + values: Record, + path: FieldPath, + fields: readonly SourceProperty[], + context: DocumentContext, +): Pick { + const properties = new Map(); + const identifiers: DocumentIdentifier[] = []; + for (const { source, name } of fields) { + if (name == null) continue; + const sources = properties.get(name) ?? []; + sources.push(source); + properties.set(name, sources); + if (!IDENTIFIER_PROPERTIES.has(name)) continue; + const value = values[source]; + const entries: [unknown, FieldPath][] = Array.isArray(value) + ? value.map((entry, index) => [entry, [...path, source, index]]) + : [[value, [...path, source]]]; + for (const [entry, entryPath] of entries) { + if (typeof entry !== "string") continue; + identifiers.push({ + property: name, + path: entryPath, + value: entry, + iri: expandIdentifier(entry, context), + }); + } + } + return { properties, identifiers }; +} + +// The standard name of an extension node object's key, matched by IRI +// whatever its container, as a JSON-LD processor reads it, and whether its +// strings are coerced as the standard property's are. +function extensionProperty( + source: string, + context: DocumentContext, +): { readonly name: string; readonly compatible: boolean } | undefined { + const { name, iri, compatible } = resolveProperty(source, context); + if (name != null) return { name, compatible }; + if (iri == null) return undefined; + const type = context.terms.get(source)?.type; + for (const [standard, term] of getContextTables().standardTerms) { + if (term.iri === iri) { + return { name: standard, compatible: term.type === type }; + } + } + return undefined; +} + +// References must be absolute IRIs, or blank node identifiers, which name +// no resource; relative ones need a base-IRI policy. +const isReadableReference = (value: string): boolean => + value.startsWith("_:") || + (/^[a-z][a-z\d+.-]*:/iu.test(value) && URL.canParse(value)); + +function typeKind(iri: string): DocumentNodeKind | undefined { + const Type = + iri === Link.typeId.href + ? Link + : iri === Mention.typeId.href + ? Mention + : getEntityTypeById(iri); + return Type == null ? undefined : nodeKind(Type); +} + +// The entries of a value, through arrays and @set or @list objects. One +// with a @context of its own is an entry itself, as its values are unread. +function valueEntries( + value: unknown, + path: FieldPath, + context: DocumentContext, +): [unknown, FieldPath][] { + if (Array.isArray(value)) { + return value.flatMap((entry, index) => + valueEntries(entry, [...path, index], context), + ); + } + if (value !== null && typeof value === "object") { + const values = value as Record; + if (Object.hasOwn(values, "@context")) return [[value, path]]; + const wrapper = Object.keys(values).find((key) => { + const keyword = keywordOf(key, context); + return keyword === "@set" || keyword === "@list"; + }); + if (wrapper != null) { + return valueEntries(values[wrapper], [...path, wrapper], context); + } + } + return [[value, path]]; +} + +// What a node object in an extension value holds, as read so far. +interface ExtensionReading { + readonly identifiers: DocumentIdentifier[]; + readonly types: string[]; + readonly children: [FieldPath, string][]; +} + +// Read one entry of a standard property of a node object in an extension +// value, and tell false when it is what the checker cannot read as a +// reference. +function readExtensionEntry( + entry: unknown, + path: FieldPath, + property: { readonly name: string; readonly compatible: boolean }, + context: DocumentContext, + reading: ExtensionReading, +): boolean { + const { name, compatible } = property; + const reference = IDENTIFIER_PROPERTIES.has(name); + if (typeof entry === "string") { + if (name === "type") { + reading.types.push( + context.terms.get(entry)?.iri ?? expandIdentifier(entry, context), + ); + return true; + } + if (!reference) return true; + const iri = expandIdentifier(entry, context); + // A literal or a relative reference names no resource the checker can + // tell, nor does a blank node but as an id: elsewhere, it may name a + // node object defined apart from the one that refers to it. + if (!compatible || !isReadableReference(iri)) return false; + // It is still listed, so that identity checks follow it. + reading.identifiers.push({ property: name, path, value: entry, iri }); + return !iri.startsWith("_:") || name === "id"; + } + // Null stands for no value. + if (entry === null) return true; + if (typeof entry !== "object") return !reference; + if (isNodeObject(entry as Record, context)) { + reading.children.push([path, name]); + return true; + } + return !reference; +} + +// Tell whether an id or href has more than one value, or a node object. +function hasAmbiguousReference(reading: ExtensionReading): boolean { + return FUNCTIONAL_REFERENCES.some( + (name) => + reading.identifiers.filter(({ property }) => property === name).length > + 1 || reading.children.some(([, property]) => property === name), + ); +} + +// Describe a node object in an extension value, with the node objects its +// standard properties hold and the names of those properties. +function describeExtensionNode( + values: Record, + path: FieldPath, + context: DocumentContext | null, +): { + readonly node: ExtensionNode; + readonly children: readonly (readonly [FieldPath, string])[]; +} { + if (context == null) { + return { + node: { + path, + uninterpreted: true, + properties: new Map(), + identifiers: [], + types: [], + }, + children: [], + }; + } + const properties = new Map(); + const reading: ExtensionReading = { + identifiers: [], + types: [], + children: [], + }; + let readable = true; + for (const source of Object.keys(values).sort()) { + // Nested properties belong to the node object around them, and reverse + // ones make other node objects the subjects; neither is read that way. + const keyword = keywordOf(source, context); + if (keyword === "@nest" || keyword === "@reverse") readable = false; + const property = extensionProperty(source, context); + if (property == null) continue; + const { name } = property; + properties.set(name, [...(properties.get(name) ?? []), source]); + // A language map holds strings in languages, not references. + if ( + context.terms.get(source)?.container === "@language" && + isLanguageMap(values[source]) + ) { + if (IDENTIFIER_PROPERTIES.has(name) && values[source] != null) { + readable = false; + } + continue; + } + for (const [entry, entryPath] of valueEntries( + values[source], + [...path, source], + context, + )) { + if (!readExtensionEntry(entry, entryPath, property, context, reading)) { + readable = false; + } + } + } + const { identifiers, types, children } = reading; + const kinds = types.map(typeKind).filter((kind) => kind != null); + const kind = kinds.includes("link") ? "link" : kinds[0]; + return { + node: { + path, + uninterpreted: !readable || hasAmbiguousReference(reading), + properties, + identifiers, + types, + ...(kind == null ? {} : { kind }), + }, + children, + }; +} + +// Find the objects in the language maps of a document's or a node object's +// keys, which hold strings in languages: an object there is no valid +// JSON-LD, and is not read. +function languageMapObjects( + values: Record, + path: FieldPath, + context: DocumentContext, +): FieldPath[] { + return Object.keys(values).flatMap((source) => { + if (context.terms.get(source)?.container !== "@language") return []; + const map = values[source]; + if (!isLanguageMap(map)) return []; + return Object.entries(map as Record).flatMap( + ([language, entry]) => { + const entries: [unknown, FieldPath][] = Array.isArray(entry) + ? entry.map((item, index) => [ + item, + [...path, source, language, index], + ]) + : [[entry, [...path, source, language]]]; + return entries + .filter(([item]) => item !== null && typeof item === "object") + .map(([, itemPath]) => itemPath); + }, + ); + }); +} + +// Name the property of its parent that embeds each value, which sits at +// [...parent, key] or [...parent, key, index]. +function nameEmbeddingProperties< + T extends Pick, +>(nodes: readonly T[]): T[] { + const byPath = new Map( + nodes.map((node) => [JSON.stringify(node.path), node]), + ); + return nodes.map((node) => { + for (const length of [node.path.length - 1, node.path.length - 2]) { + const parent = byPath.get(JSON.stringify(node.path.slice(0, length))); + const key = node.path[length]; + if (parent == null || typeof key !== "string") continue; + if ( + length < node.path.length - 1 && + typeof node.path.at(-1) !== "number" + ) { + continue; + } + for (const [name, sources] of parent.properties) { + if (sources.includes(key)) return { ...node, property: name }; + } + } + return node; + }); +} + /** * Describes the vocabulary documents the checker reads, so that write paths * interpret aliases, full-IRI keys, and keyword aliases exactly as * validation did, instead of matching literal key spellings. Opaque - * extension values are not descended into. + * extension values are not read as vocabulary documents, but the JSON-LD + * node objects in them are described as extensions of the document that + * holds them. * @returns The root and every embedded document in traversal order, or * `null` unless validation completes without errors. */ @@ -2513,6 +3006,11 @@ export function describeDocument( readonly Type: VocabularyType; readonly fields: readonly SourceProperty[]; }[] = []; + const extensions: { + readonly values: Record; + readonly path: FieldPath; + readonly context: DocumentContext | null; + }[] = []; checkDocumentValue( document, [], @@ -2521,6 +3019,10 @@ export function describeDocument( (values, path, context, Type, fields) => { visited.push({ values, path, context, Type, fields }); }, + undefined, + (values, path, context) => { + extensions.push({ values, path, context }); + }, ); const result = reporter.result(); if ( @@ -2531,53 +3033,83 @@ export function describeDocument( } // Read values only now: validation has rejected sparse arrays, accessors, // and other non-JSON shapes without evaluating them. - const nodes = visited.map( - ({ values, path, context, Type, fields }): DocumentNode => { - const properties = new Map(); - const identifiers: DocumentIdentifier[] = []; - for (const { source, name, compatible } of fields) { - if (name == null || !compatible) continue; - const sources = properties.get(name) ?? []; - sources.push(source); - properties.set(name, sources); - if (!identifierProperties.has(name)) continue; - const value = values[source]; - const entries: [unknown, FieldPath][] = Array.isArray(value) - ? value.map((entry, index) => [entry, [...path, source, index]]) - : [[value, [...path, source]]]; - for (const [entry, entryPath] of entries) { - if (typeof entry !== "string") continue; - identifiers.push({ - property: name, - path: entryPath, - value: entry, - iri: expandIdentifier(entry, context), - }); - } - } - return { - path, - type: Type.typeId.href, - kind: nodeKind(Type), - properties, - identifiers, - }; - }, + const documents = visited.map(({ values, path, context, Type, fields }) => ({ + path, + type: Type.typeId.href, + kind: nodeKind(Type), + ...describeProperties( + values, + path, + fields.filter(({ compatible }) => compatible), + context, + ), + })); + const described = [ + ...extensions.map(({ values, path, context }) => + describeExtensionNode(values, path, context), + ), + ...[...visited, ...extensions].flatMap(({ values, path, context }) => + context == null + ? [] + : languageMapObjects(values, path, context).map((object) => + describeExtensionNode({}, object, null), + ), + ), + ]; + const holders = new Map( + described.flatMap(({ node, children }) => + children.map(([path, property]) => [ + JSON.stringify(path), + { property, parent: node.path }, + ]), + ), ); - const byPath = new Map( - nodes.map((node) => [JSON.stringify(node.path), node]), + // Node objects that share an id are one node, and one whose id another + // value refers to says more of what that value names, so a node object + // whose id is used elsewhere in the document is not read apart from it. + // Its own properties, such as a Link's href naming its own id, name no + // other node to join. + const uses = Map.groupBy( + [...documents, ...described.map(({ node }) => node)].flatMap( + ({ identifiers }) => identifiers, + ), + ({ iri }) => iri, ); - return nodes.map((node) => { - if (node.path.length === 0) return node; - // An embedded document sits at [...parent, key] or [...parent, key, index]. - for (const length of [node.path.length - 1, node.path.length - 2]) { - const parent = byPath.get(JSON.stringify(node.path.slice(0, length))); - const key = node.path[length]; - if (parent == null || typeof key !== "string") continue; - for (const [name, sources] of parent.properties) { - if (sources.includes(key)) return { ...node, property: name }; - } - } - return node; + const extensionNodes = described.map(({ node }): ExtensionNode => { + const id = node.identifiers.find(({ property }) => property === "id"); + // Every use of its id in its own properties is among the uses; counting + // keeps this linear in a long list of them. + const joined = + id != null && + (uses.get(id.iri)?.length ?? 0) > + node.identifiers.filter(({ iri }) => iri === id.iri).length; + return { + ...node, + ...holders.get(JSON.stringify(node.path)), + ...(joined ? { uninterpreted: true } : {}), + }; }); + const owners = new Map( + visited.map(({ path }): [string, ExtensionNode[]] => [ + JSON.stringify(path), + [], + ]), + ); + for (const node of extensionNodes) { + // A node object belongs to the closest document around it. + for (let length = node.path.length - 1; length >= 0; length -= 1) { + const owner = owners.get(JSON.stringify(node.path.slice(0, length))); + if (owner == null) continue; + owner.push(node); + break; + } + } + return nameEmbeddingProperties( + documents.map( + (node): DocumentNode => ({ + ...node, + extensions: owners.get(JSON.stringify(node.path)) ?? [], + }), + ), + ); }