From 4ca9352725f9525a6a7b021e8bdbd3bddab95608 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Wed, 4 Nov 2026 22:23:59 +0900 Subject: [PATCH 01/25] Serve saved activities and objects over ActivityPub Resources saved by the common write operations had revisions only, so no dispatcher served them and /ap/activities/{id} always answered 404. The dispatchers now serve a resource without legacy typed rows from the document of its current revision, as saved, without bto and bcc in it or in any document it embeds. Legacy Note, Article and Create rows keep their serializers, and no IRI changes. Every read needs the request's authority to be the instance of a live local actor and Public to or cc addressing. An activity must also be in such an actor's outbox. A document that embeds content addressed to others, or a deleted local object, at any depth is not served, and an activity referring to a deleted object is served only if it is a Delete. A deleted object is answered with a Tombstone. Fedify does not parse a served document: a vocabulary object whose toJsonLd() returns the document is handed to it, so values Fedify 2.4.3 cannot read, such as a Link in audience or object, are served as saved too. Outbox pages now follow collection_items positions and mix legacy and saved activities in one order; the cursor is the position of the previous page's last item. The serialization is exported as @drfed/federation/serving for outgoing payloads. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to carry out a local plan for #153. Claude Code wrote the serving modules, the shared SQL predicates, the outbox paging, the tests, the documentation and the changelog entries. The contributor then supplied review findings on nested visibility, deletion inside embedded documents, documents without @context, embedded Public recipients and Link values; Claude Code reproduced each with a test before fixing it. Codex reviewed the result and found that a Link href written as a singleton list escaped the deletion rule, which Claude Code fixed with a regression test. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra --- CHANGES.md | 16 + CONTRIBUTING.md | 6 + changes.d/drfed/serve-saved-activities.md | 14 + changes.d/javascript/federation-serving.md | 9 + docs/.vitepress/config.mts | 1 + docs/internals/federation-reads.md | 117 ++ docs/internals/local-writes.md | 8 +- docs/internals/resource-revisions.md | 15 +- packages/federation/README.md | 14 + packages/federation/package.json | 7 +- packages/federation/src/collection.ts | 113 +- packages/federation/src/federation.test.ts | 63 +- .../src/local-write-postgres.test.ts | 112 ++ packages/federation/src/local-write.test.ts | 4 +- packages/federation/src/object-dispatchers.ts | 65 +- packages/federation/src/serving.test.ts | 1059 +++++++++++++++++ packages/federation/src/serving.ts | 35 + packages/federation/src/serving/document.ts | 280 +++++ packages/federation/src/serving/resource.ts | 436 +++++++ packages/federation/src/serving/vocab.ts | 114 ++ packages/federation/src/visibility.ts | 142 ++- 21 files changed, 2462 insertions(+), 168 deletions(-) create mode 100644 changes.d/drfed/serve-saved-activities.md create mode 100644 changes.d/javascript/federation-serving.md create mode 100644 docs/internals/federation-reads.md create mode 100644 packages/federation/src/serving.test.ts create mode 100644 packages/federation/src/serving.ts create mode 100644 packages/federation/src/serving/document.ts create mode 100644 packages/federation/src/serving/resource.ts create mode 100644 packages/federation/src/serving/vocab.ts diff --git a/CHANGES.md b/CHANGES.md index 7534fe27..38790f8f 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. 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 is not served. + Existing IRIs do not change. + [[#153] 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,6 +38,7 @@ 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 @@ -73,6 +84,11 @@ 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] 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 3e32682e..bc21e40e 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 00000000..94237bf3 --- /dev/null +++ b/changes.d/drfed/serve-saved-activities.md @@ -0,0 +1,14 @@ +--- +links: + '#153': https://github.com/fedify-dev/drfed/issues/153 +--- + - 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. 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 is not served. + Existing IRIs do not change. + [[#153] by ChanHaeng Lee] diff --git a/changes.d/javascript/federation-serving.md b/changes.d/javascript/federation-serving.md new file mode 100644 index 00000000..af371038 --- /dev/null +++ b/changes.d/javascript/federation-serving.md @@ -0,0 +1,9 @@ +--- +links: + '#153': https://github.com/fedify-dev/drfed/issues/153 +--- + - 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] by ChanHaeng Lee] diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8aae71e8..e2eec63c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -165,6 +165,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 00000000..a248a3f0 --- /dev/null +++ b/docs/internals/federation-reads.md @@ -0,0 +1,117 @@ +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 and from every embedded + document. They are found by meaning through `describeDocument()`, so + aliases of them are removed as well. Opaque extension values are not + descended into. + - 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. + + +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 + activity whose `object` refers to a deleted local object is served only + if it is a `Delete`: the deletion hides the `Like` and other activities + about the object, and leaves its `Delete` retrievable. 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. + - 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. The cursor is the position of the last item of the +previous page. A page leaves out the saved activities their documents do not +allow after selecting them, so it can hold fewer items than the page size. +The counter counts what the pages hold, reading the documents of the saved +activities in the outbox. + + +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. + - `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 9b1b5a18..679239f9 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`, @@ -154,8 +154,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 e089609f..5a72060e 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/packages/federation/README.md b/packages/federation/README.md index ccef7d97..08287fe7 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 ------------------- diff --git a/packages/federation/package.json b/packages/federation/package.json index ac4526a9..22fb75ac 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 0b0eabb3..e0e337ae 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 ba006d65..ff4b06a4 100644 --- a/packages/federation/src/federation.test.ts +++ b/packages/federation/src/federation.test.ts @@ -518,7 +518,7 @@ 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()}`, { @@ -592,11 +592,13 @@ describe("ActivityPub outbox", () => { { 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, @@ -607,23 +609,32 @@ 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( @@ -635,21 +646,23 @@ describe("ActivityPub outbox", () => { }; const first = await fetchJson(`${actorIri}/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), diff --git a/packages/federation/src/local-write-postgres.test.ts b/packages/federation/src/local-write-postgres.test.ts index 7067886e..463e61fc 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -25,12 +25,17 @@ 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 { 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"; @@ -304,3 +309,110 @@ 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; +} + +describe("federation reads on PostgreSQL", () => { + 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 actorIri = `https://test-instance.drfed.org/users/${localActorId}`; + const get = async (iri: string) => { + 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) + : {}, + }; + }; + 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: { text: '안녕 "🙂" \\ é', zeta: 1, alpha: [true, null] }, + }, + resources: [{ path: [], kind: "activity" }], + publish: true, + }); + const served = await get(like!.iri); + assert.equal(served.status, 200); + assert.deepEqual(served.body, publicDocument(like!.document)); + assert.deepEqual(Object.keys(served.body.mood as object), [ + "text", + "zeta", + "alpha", + ]); + 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(`${actorIri}/outbox`); + const page = await get(`${actorIri}/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); + }); + }, + ); +}); diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index 6708aff3..dce8be5b 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -195,7 +195,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); @@ -825,7 +825,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); }); }); diff --git a/packages/federation/src/object-dispatchers.ts b/packages/federation/src/object-dispatchers.ts index 55e803b3..1ab095da 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.test.ts b/packages/federation/src/serving.test.ts new file mode 100644 index 00000000..20e7cbb0 --- /dev/null +++ b/packages/federation/src/serving.test.ts @@ -0,0 +1,1059 @@ +// 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 } from "@drfed/models/resource"; +import { loadCurrentResourceRevision } from "@drfed/models/resource-revision"; +import { type Uuid, uuidV7 } from "@drfed/models/uuid"; +import type { Federation } from "@fedify/fedify"; +import { Like } 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, + seedLocalActor, +} from "./seed.test.ts"; + +const AS = "https://www.w3.org/ns/activitystreams"; +const accountId = "00000000-0000-4000-8000-000000000301" as Uuid; +const principal = { accountId }; +const host = "test-instance.drfed.org"; +const actorIri = `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: accountId, + email: "member@example.com", + name: "Member", + created, + }); + await harness.db.insert(schema.instanceMembers).values({ + accountId, + 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() : {}, + }; +} + +/** + * 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: [`${actorIri}/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: [`${actorIri}/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: actorIri, + to: [PUBLIC_IRI], + bcc: [blind], + object: { + id: "https://a.example/activities/2", + type: "Like", + actor: actorIri, + 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: actorIri, + to: [PUBLIC_IRI], + object: { + id: "https://a.example/activities/2", + type: "Like", + actor: actorIri, + 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 descending into them", () => { + const context = [ + AS, + { ex: "https://ext.example/ns#", mood: "ex:mood" }, + ] as const; + const document = { + "@context": [...context], + id: "https://a.example/activities/1", + type: "Like", + actor: actorIri, + object: "https://remote.example/notes/1", + mood: { bcc: ["kept"], deep: [1, "x", null] }, + bcc: [blind], + }; + assert.deepEqual(publicDocument(document), { + "@context": [...context], + id: "https://a.example/activities/1", + type: "Like", + actor: actorIri, + object: "https://remote.example/notes/1", + mood: { bcc: ["kept"], deep: [1, "x", 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.match(String(body.object), new RegExp(`^${actorIri}/`, "u")), + }, + { + 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: actorIri, + }, + }, + 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: `${actorIri}/featured`, + }, + route: "activities", + check: (body) => assert.equal(body.target, `${actorIri}/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.match( + saved.iri, + new RegExp(`^https://${host}/ap/${route}/`, "u"), + ); + 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("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" ? `${actorIri}/followers` : PUBLIC_IRI, + ], + }, + "activity", + { publish: scenario !== "unpublished" }, + ); + let { iri } = saved; + if (scenario === "wrongHost") { + iri = iri.replace(host, "wrong.example"); + } else 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", + }); + iri = iri.replace(host, "other-instance.drfed.org"); + } else if (scenario === "deletedActor") { + await harness.db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + } else if (scenario === "malformed") { + iri = `https://${host}/ap/activities/bad`; + } + 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: [`${actorIri}/followers`] }, + "object", + ); + assert.equal((await get(harness.federation, followers.iri)).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: [`${actorIri}/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 = `${actorIri}/${legacy.id}`; + const createIri = `https://${host}/ap/creates/${legacy.activityId}`; + const update = await edit(env, legacy.id, (document) => ({ + ...document, + content: "

B

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

B

"); + const legacyCreate = await get(harness.federation, createIri); + assert.equal(legacyCreate.status, 200); + 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, `${actorIri}/outbox`); + const page = await get(federation, `${actorIri}/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: `${actorIri}/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: `${actorIri}/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, `${actorIri}/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("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("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: [`${actorIri}/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, `${actorIri}/outbox`); + const page = await get(harness.federation, `${actorIri}/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); + }); + }); +}); diff --git a/packages/federation/src/serving.ts b/packages/federation/src/serving.ts new file mode 100644 index 00000000..693630ad --- /dev/null +++ b/packages/federation/src/serving.ts @@ -0,0 +1,35 @@ +// 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 { + type ServedDocument, + parseVocab, + publicDocument, + serveDocument, +} 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 00000000..0cc503c7 --- /dev/null +++ b/packages/federation/src/serving/document.ts @@ -0,0 +1,280 @@ +// 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 DocumentNode, + type FieldPath, + type JsonValue, + addressingProperties, + 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 activityStreams = "https://www.w3.org/ns/activitystreams"; +/** The type IRI of `Create`, which has its own activity route. */ +export const createType = `${activityStreams}#Create`; +/** The type IRI of `Delete`, which outlives the object it deletes. */ +export const deleteType = `${activityStreams}#Delete`; +const blindProperties = ["bto", "bcc"] as const; +const recipientProperties: ReadonlySet = new Set(addressingProperties); + +const pathKey = (path: FieldPath): string => JSON.stringify(path); + +/** + * Collects each described document's blind addressing keys, whatever their + * spelling, by the document's source path. + * @returns The keys to omit, by path. + */ +const blindKeys = ( + nodes: readonly DocumentNode[], +): ReadonlyMap> => + new Map( + nodes.map((node) => [ + pathKey(node.path), + new Set( + blindProperties.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": activityStreams, ...document }; + +// An embedded document sits at [...parent, key] or [...parent, key, index]; +// one at [...parent, key, key] is embedded in another embedded document. +const isChild = (child: FieldPath, parent: FieldPath): boolean => + parent.every((step, index) => child[index] === step) && + typeof child[parent.length] === "string" && + (child.length === parent.length + 1 || + (child.length === parent.length + 2 && + typeof child[parent.length + 1] === "number")); + +/** + * 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: DocumentNode, property: string): string | null => + node.identifiers.find((entry) => entry.property === property)?.iri ?? null; + +/** + * 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. + * @returns The addressing property and IRI of each recipient. + */ +const recipients = ( + node: DocumentNode, + nodes: readonly DocumentNode[], +): { readonly property: string; readonly iri: string | null }[] => [ + ...node.identifiers + .filter(({ property }) => recipientProperties.has(property)) + .map(({ property, iri }) => ({ property, iri })), + ...nodes + .filter( + (child) => + child.property != null && + recipientProperties.has(child.property) && + isChild(child.path, node.path), + ) + .map((child) => ({ + property: child.property!, + iri: ownIdentifier(child, child.kind === "link" ? "href" : "id"), + })), +]; + +/** + * 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. + * @returns Whether the document is restricted. + */ +const isRestricted = ( + node: DocumentNode, + nodes: readonly DocumentNode[], +): boolean => { + const named = recipients(node, nodes); + return ( + named.length > 0 && + !named.some( + ({ property, iri }) => + (property === "to" || property === "cc") && iri === PUBLIC_IRI, + ) + ); +}; + +const ownId = (node: DocumentNode): string | null => ownIdentifier(node, "id"); + +/** 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, at any depth. */ + readonly embedded: readonly string[]; + /** + * The IRIs the root's `object` refers to without embedding them, as + * references or `Link` values. + */ + readonly objectReferences: readonly string[]; + /** + * Whether an embedded document, 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; +} + +/** + * 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", + ); + return { + served: { + type: root.type, + id: ownId(root), + document: statedContext( + omitKeys(document, [], blindKeys(nodes)) as JsonObject, + ), + }, + embedded: embedded.map(ownId).filter((iri) => iri != null), + objectReferences: [ + ...root.identifiers + .filter(({ property }) => property === "object") + .map(({ iri }) => iri), + // A Link names the object it refers to by its href. + ...nodes + .filter( + (node) => + node.property === "object" && + node.kind === "link" && + isChild(node.path, root.path), + ) + .map((node) => ownIdentifier(node, "href")) + .filter((iri) => iri != null), + ], + restricted: embedded.some((node) => isRestricted(node, nodes)), + }; +} + +/** + * 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, which is then not served. + */ +export function serveDocument(document: JsonObject): ServedDocument | null { + return inspectDocument(document)?.served ?? null; +} + +/** + * Removes blind recipients from a saved document for public or federated + * output: the `bto` and `bcc` of the root and of every embedded document, + * found by meaning, so that aliases, full-IRI keys and compact IRI keys are + * removed as well. Opaque extension values are not descended into, since + * the validator reads no addressing in them. 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, which is then 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 00000000..7ae679c6 --- /dev/null +++ b/packages/federation/src/serving/resource.ts @@ -0,0 +1,436 @@ +// 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 { + type SQL, + and, + asc, + eq, + gt, + inArray, + isNotNull, + 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, + publicAddressing, + servedActivity, + servedObjectRevision, +} from "../visibility.ts"; +import { + type DocumentInspection, + type ServedDocument, + createType, + deleteType, + inspectDocument, +} from "./document.ts"; +import { documentActivity, documentObject } from "./vocab.ts"; + +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 document: unknown; +} + +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Reads a saved revision's document for serving. + * @returns The inspection. + * @throws {Error} If the saved document is not one the write path saves: one + * the validator reads, naming the resource it is saved as. + */ +function inspectRevision(row: RevisionRow): DocumentInspection { + const inspection = isJsonObject(row.document) + ? inspectDocument(row.document) + : null; + if (inspection == null || inspection.served.id !== row.iri) { + throw new Error(`The saved document of ${row.iri} cannot be served.`); + } + return inspection; +} + +/** + * Applies the rules only a saved document can tell. Content embedded at any + * depth 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 whose `object` + * refers to a deleted local object is served only when it is a `Delete`. + * @param deleted The deleted local objects among what the document embeds + * and refers to. + * @returns Whether the document is served. + */ +function isServable( + inspection: DocumentInspection, + deleted: ReadonlySet, + kind: "activity" | "object", +): boolean { + if (inspection.restricted) return false; + if (inspection.embedded.some((iri) => deleted.has(iri))) return false; + return ( + kind === "object" || + inspection.served.type === deleteType || + !inspection.objectReferences.some((iri) => deleted.has(iri)) + ); +} + +/** + * Finds the deleted local objects among IRIs. + * @returns The IRIs of those objects. + */ +async function deletedObjects( + db: Database, + iris: readonly string[], +): Promise> { + if (iris.length === 0) return new Set(); + const rows = await db + .select({ iri: resources.iri }) + .from(resources) + .where( + and(inArray(resources.iri, [...iris]), deletedLocalObject(resources.id)), + ); + return new Set(rows.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. + * @throws {Error} If a saved document cannot be served. + */ +async function servedRevisions( + db: Database, + rows: readonly RevisionRow[], + kind: "activity" | "object", +): Promise<(ServedDocument | null)[]> { + const inspections = rows.map(inspectRevision); + const deleted = await deletedObjects(db, [ + ...new Set( + inspections.flatMap(({ embedded, objectReferences }) => [ + ...embedded, + ...objectReferences, + ]), + ), + ]); + return inspections.map((inspection) => + isServable(inspection, deleted, kind) ? 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`, + 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 servedRevisions(db, [row], "activity"); + if (served == null || (served.type === createType) !== (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, + 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 servedRevisions(db, [row], "object"); + 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), + isNotNull(collectionItems.position), + servedActivity(collectionItems.itemId), + )!; + +/** + * 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. + * @param after The position the page starts after; `null` for the first. + * @returns The page, which may hold fewer activities than the limit when + * some on it are not served. + * @throws {Error} If an activity on the page cannot be serialized. + */ +export async function loadOutboxPage( + db: Database, + ctx: Context, + outboxId: Uuid, + after: number | null, + limit: number, +): Promise { + const rows = await db + .select({ + id: collectionItems.itemId, + position: collectionItems.position, + iri: resources.iri, + typed: sql`${activities.id} is not null`, + 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(limit + 1); + const page = rows.slice(0, limit); + const typedIds = page.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 = page.filter((row) => !row.typed); + const served = new Map( + (await servedRevisions(db, saved, "activity")).map((document, index) => [ + saved[index]!.id, + document, + ]), + ); + const items = page.map((row): Activity | null => { + if (row.typed) { + const activity = byId.get(row.id); + return activity == null ? null : toCreate(ctx, activity); + } + const document = served.get(row.id); + return document == null ? null : documentActivity(document); + }); + return { + items: items.filter((item) => item != null), + nextCursor: rows.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, + * 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)); + const saved = await db + .select({ iri: resources.iri, 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))); + const served = await servedRevisions(db, saved, "activity"); + return ( + (legacy?.count ?? 0) + served.filter((document) => document != null).length + ); +} + +/** A saved activity's current document, as #145 dispatches it. */ +export interface ActivitySnapshot { + 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 00000000..efd47642 --- /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 { type ServedDocument, createType } 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 === createType + ? 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 1e9945c7..e662e1d1 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,123 @@ 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 resources 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 ${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} + ))`; } From 114875f109def33264c3d4d7d1fc76474d22cf85 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 5 Nov 2026 09:50:52 +0900 Subject: [PATCH 02/25] Keep outbox reads working past unservable items Three gaps in the outbox reads of saved activities: - A saved document the write path would refuse, as after a manual edit, made inspectRevision() throw, so one bad activity answered the whole outbox page and counter with 500. It is now logged under ["drfed", "federation", "serving"] and left out; its own resource answers 404. - A page left out the activities its documents hide only after selecting rows, so it could be empty while still linking a next page, and consumers that stop at an empty page missed the rest. A page now reads further batches until it is full, and links a next page only when a served activity follows. - Pages required an item position but direct activity reads did not. Both now share listedItem(), so an item no page can reach is not served either. The counter still reads every saved document in the outbox on each request; the internals document now states that cost. https://github.com/fedify-dev/drfed/issues/153 The contributor supplied the review findings. Claude Code reproduced each with a test before fixing it and updated the documentation. Assisted-by: Claude Code:claude-opus-5-5 --- docs/internals/federation-reads.md | 21 ++- packages/federation/README.md | 5 +- packages/federation/src/serving.test.ts | 147 +++++++++++++++++++- packages/federation/src/serving/resource.ts | 133 ++++++++++++------ packages/federation/src/visibility.ts | 18 ++- 5 files changed, 267 insertions(+), 57 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index a248a3f0..637fcdd2 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -49,6 +49,10 @@ is served from the document of its current revision: 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. Conditions @@ -89,11 +93,18 @@ 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. The cursor is the position of the last item of the -previous page. A page leaves out the saved activities their documents do not -allow after selecting them, so it can hold fewer items than the page size. -The counter counts what the pages hold, reading the documents of the saved -activities in the outbox. +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. 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, so its cost grows with the outbox. Outgoing payloads diff --git a/packages/federation/README.md b/packages/federation/README.md index 08287fe7..8edc3e11 100644 --- a/packages/federation/README.md +++ b/packages/federation/README.md @@ -130,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/src/serving.test.ts b/packages/federation/src/serving.test.ts index 20e7cbb0..9117289d 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -36,8 +36,16 @@ import { publicDocument, } from "@drfed/federation/serving"; import { type Database, schema } from "@drfed/models"; -import { PUBLIC_IRI } from "@drfed/models/resource"; -import { loadCurrentResourceRevision } from "@drfed/models/resource-revision"; +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 } from "@fedify/vocab"; @@ -1056,4 +1064,139 @@ describe("outboxes of saved and legacy activities", () => { assert.equal(page.body.next, undefined); }); }); + + 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: actorIri, + 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, `${actorIri}/outbox`); + const page = await get(harness.federation, `${actorIri}/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 a page holds, between served ones. + for (let index = 0; index < 21; 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, `${actorIri}/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, `${actorIri}/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, `${actorIri}/outbox`); + assert.equal(collection.body.totalItems, 0); + }); + }); }); diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index 7ae679c6..9036e77a 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -19,17 +19,8 @@ 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 { - type SQL, - and, - asc, - eq, - gt, - inArray, - isNotNull, - isNull, - sql, -} from "drizzle-orm"; +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"; @@ -43,6 +34,7 @@ import { canonicalizeAuthority } from "../origin.ts"; import { deletedLocalObject, inLocalOutbox, + listedItem, publicAddressing, servedActivity, servedObjectRevision, @@ -56,6 +48,8 @@ import { } from "./document.ts"; import { documentActivity, documentObject } from "./vocab.ts"; +const logger = getLogger(["drfed", "federation", "serving"]); + const { activities, collectionItems, @@ -82,19 +76,26 @@ function isJsonObject(value: unknown): value is JsonObject { } /** - * Reads a saved revision's document for serving. - * @returns The inspection. - * @throws {Error} If the saved document is not one the write path saves: one - * the validator reads, naming the resource it is saved as. + * Reads a saved revision's document for serving. 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 and + * left out, so that it takes down nothing but its own resource. + * @returns The inspection, or `null` when the document cannot be served. */ -function inspectRevision(row: RevisionRow): DocumentInspection { +function inspectRevision(row: RevisionRow): DocumentInspection | null { const inspection = isJsonObject(row.document) ? inspectDocument(row.document) : null; - if (inspection == null || inspection.served.id !== row.iri) { - throw new Error(`The saved document of ${row.iri} cannot be served.`); + if (inspection != null && inspection.served.id === row.iri) { + return inspection; } - return inspection; + logger.error( + "The saved document of {iri} cannot be served; leaving it out.", + { + iri: row.iri, + }, + ); + return null; } /** @@ -143,7 +144,6 @@ async function deletedObjects( * objects they embed or refer to. * @returns For each revision, its served form, or `null` when it is not * served. - * @throws {Error} If a saved document cannot be served. */ async function servedRevisions( db: Database, @@ -153,14 +153,17 @@ async function servedRevisions( const inspections = rows.map(inspectRevision); const deleted = await deletedObjects(db, [ ...new Set( - inspections.flatMap(({ embedded, objectReferences }) => [ - ...embedded, - ...objectReferences, - ]), + inspections.flatMap((inspection) => + inspection == null + ? [] + : [...inspection.embedded, ...inspection.objectReferences], + ), ), ]); return inspections.map((inspection) => - isServable(inspection, deleted, kind) ? inspection.served : null, + inspection != null && isServable(inspection, deleted, kind) + ? inspection.served + : null, ); } @@ -293,26 +296,25 @@ export interface OutboxPage { const outboxItems = (outboxId: Uuid): SQL => and( eq(collectionItems.collectionId, outboxId), - isNotNull(collectionItems.position), + listedItem(collectionItems.position), servedActivity(collectionItems.itemId), )!; /** - * 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. - * @param after The position the page starts after; `null` for the first. - * @returns The page, which may hold fewer activities than the limit when - * some on it are not served. - * @throws {Error} If an activity on the page cannot be serialized. + * 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. */ -export async function loadOutboxPage( +async function loadOutboxBatch( db: Database, ctx: Context, outboxId: Uuid, after: number | null, - limit: number, -): Promise { + size: number, +): Promise< + { readonly position: number; readonly activity: Activity | null }[] +> { const rows = await db .select({ id: collectionItems.itemId, @@ -339,9 +341,8 @@ export async function loadOutboxPage( ), ) .orderBy(asc(collectionItems.position)) - .limit(limit + 1); - const page = rows.slice(0, limit); - const typedIds = page.filter(({ typed }) => typed).map(({ id }) => id); + .limit(size); + const typedIds = rows.filter(({ typed }) => typed).map(({ id }) => id); const typed = typedIds.length === 0 ? [] @@ -350,24 +351,66 @@ export async function loadOutboxPage( with: activitySelection, }); const byId = new Map(typed.map((activity) => [activity.id, activity])); - const saved = page.filter((row) => !row.typed); + const saved = rows.filter((row) => !row.typed); const served = new Map( (await servedRevisions(db, saved, "activity")).map((document, index) => [ saved[index]!.id, document, ]), ); - const items = page.map((row): Activity | null => { + return rows.map((row) => { + // Listed items have positions; see listedItem(). + const position = row.position!; if (row.typed) { const activity = byId.get(row.id); - return activity == null ? null : toCreate(ctx, activity); + return { + position, + activity: activity == null ? null : toCreate(ctx, activity), + }; } const document = served.get(row.id); - return document == null ? null : documentActivity(document); + 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 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; + // 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, limit + 1); + for (const { position, activity } of batch) { + if (activity != null) items.push({ position, activity }); + } + if (batch.length <= limit) break; + cursor = batch.at(-1)!.position; + } + const page = items.slice(0, limit); return { - items: items.filter((item) => item != null), - nextCursor: rows.length > limit ? page.at(-1)!.position : null, + items: page.map(({ activity }) => activity), + nextCursor: items.length > limit ? page.at(-1)!.position : null, }; } diff --git a/packages/federation/src/visibility.ts b/packages/federation/src/visibility.ts index e662e1d1..ca951aff 100644 --- a/packages/federation/src/visibility.ts +++ b/packages/federation/src/visibility.ts @@ -120,9 +120,20 @@ export function deletedLocalObject(id: SQLWrapper): SQL { } /** - * Matches the resources 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. + * 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. @@ -134,6 +145,7 @@ export function inLocalOutbox(id: SQLWrapper, host: string): SQL { 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} From fb36e4783eaf345af3901e5d6dc8f46321f888c8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 15:09:29 +0900 Subject: [PATCH 03/25] Read extension values and nested activities when serving Gaps that a review of the serving changes found: - A node object inside an extension value, such as a Note under a custom property, kept its bto and bcc, and its addressing did not restrict the document around it, although a JSON-LD processor reads it with the context it inherits. describeDocument() now lists such node objects as the extensions of the document that holds them. Their blind recipients are removed, and the visibility and deletion rules read them as embedded documents. A document with one that has a @context of its own is neither served nor returned by publicDocument(), since the validator does not read that context. - Only the root's object was checked against deleted objects, so an Undo of a Like of a deleted note was still served. The object of every embedded activity other than a Delete is checked as well. - The outbox counter looked up deleted objects with every IRI of the outbox in one statement, which fails past the 65,535 parameters PostgreSQL binds. Lookups now take 1,000 IRIs at a time, and the counter reads documents 100 at a time. - Pages read past hidden activities 21 at a time; their batches now double up to 1,000 items. An unservable revision is logged once per process instead of on every page and counter request. Tests also cover saved objects and their Tombstones requested on a host that is no instance, on another instance's authority, and after the actor is deleted. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex, which also cross-checked each other's findings. Claude Code verified each finding against the code, reproduced the visibility and deletion gaps with tests that failed before the fix, and wrote the changes, tests, documentation and changelog entries. A finding that nested activities advertise IRIs answering 404 was left for the contributor, since publishing only the root activity is the documented contract. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- CHANGES.md | 19 +- changes.d/drfed/serve-saved-activities.md | 14 +- changes.d/javascript/federation-serving.md | 6 + docs/internals/federation-reads.md | 39 ++- packages/federation/src/serving.test.ts | 224 +++++++++++++++--- packages/federation/src/serving/document.ts | 160 +++++++++---- packages/federation/src/serving/resource.ts | 167 ++++++++----- packages/models/README.md | 20 +- packages/models/src/document.test.ts | 69 ++++++ packages/models/src/document.ts | 249 ++++++++++++++++---- 10 files changed, 752 insertions(+), 215 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 38790f8f..a5ca7e13 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,13 +10,13 @@ To be released. - 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. 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 is not served. - Existing IRIs do not change. + 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] by ChanHaeng Lee\] - Added immutable document histories for local object creation and accepted incoming Create activities. Database upgrades retain existing object and @@ -89,6 +89,11 @@ To be released. documents, `parseVocab()`, which parses such a document into a Fedify vocabulary object, and `loadActivitySnapshot()`. [[#153] 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 and + their IRI references. + [[#153] 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/changes.d/drfed/serve-saved-activities.md b/changes.d/drfed/serve-saved-activities.md index 94237bf3..89b7c278 100644 --- a/changes.d/drfed/serve-saved-activities.md +++ b/changes.d/drfed/serve-saved-activities.md @@ -4,11 +4,11 @@ links: --- - 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. 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 is not served. - Existing IRIs do not change. + 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] by ChanHaeng Lee] diff --git a/changes.d/javascript/federation-serving.md b/changes.d/javascript/federation-serving.md index af371038..4a750d2a 100644 --- a/changes.d/javascript/federation-serving.md +++ b/changes.d/javascript/federation-serving.md @@ -7,3 +7,9 @@ links: documents, `parseVocab()`, which parses such a document into a Fedify vocabulary object, and `loadActivitySnapshot()`. [[#153] 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 and + their IRI references. + [[#153] by ChanHaeng Lee] diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 637fcdd2..75d0bdbd 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -38,10 +38,11 @@ is served from the document of its current revision: 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 and from every embedded - document. They are found by meaning through `describeDocument()`, so - aliases of them are removed as well. Opaque extension values are not - descended into. + - `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. JSON literals stay as saved. - 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. @@ -53,6 +54,7 @@ is served from the document of its current revision: 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. Conditions @@ -65,9 +67,11 @@ actor, and Public `to` or `cc` addressing in the resource's current - 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 - activity whose `object` refers to a deleted local object is served only - if it is a `Delete`: the deletion hides the `Like` and other activities - about the object, and leaves its `Delete` retrievable. A local object is + 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 @@ -80,6 +84,11 @@ actor, and Public `to` or `cc` addressing in the resource's current 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 two rules read the node objects in extension values as embedded + documents, with recipients by `id` or `href`. A saved document with a + node object in an extension value that has a `@context` of its own is + not served, since the validator does not read that context and its + blind recipients cannot be found. - 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. @@ -98,13 +107,16 @@ 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. 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. +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, so its cost grows with the outbox. +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 @@ -115,7 +127,8 @@ payloads: - `publicDocument(document)` returns a copy without blind recipients and with a stated context, or `null` for a document the validator cannot - read offline. + 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`. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 9117289d..3d4cbaff 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -182,6 +182,39 @@ async function get( }; } +/** + * 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. @@ -289,18 +322,19 @@ describe("publicDocument()", () => { }); }); - it("keeps the context and extension values, without descending into them", () => { + 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: actorIri, object: "https://remote.example/notes/1", - mood: { bcc: ["kept"], deep: [1, "x", null] }, + mood: { bcc: [blind], deep: [1, "x", null, { bto: blind }], literal }, bcc: [blind], }; assert.deepEqual(publicDocument(document), { @@ -309,8 +343,16 @@ describe("publicDocument()", () => { type: "Like", actor: actorIri, object: "https://remote.example/notes/1", - mood: { bcc: ["kept"], deep: [1, "x", null] }, + mood: { deep: [1, "x", null, {}], literal }, }); + // 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", () => { @@ -520,29 +562,12 @@ describe("federation reads of saved activities", () => { "activity", { publish: scenario !== "unpublished" }, ); - let { iri } = saved; - if (scenario === "wrongHost") { - iri = iri.replace(host, "wrong.example"); - } else 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", - }); - iri = iri.replace(host, "other-instance.drfed.org"); - } else if (scenario === "deletedActor") { - await harness.db - .update(schema.actors) - .set({ deleted: Temporal.Now.instant() }) - .where(eq(schema.actors.id, localActorId)); - } else if (scenario === "malformed") { - iri = `https://${host}/ap/activities/bad`; - } + 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); }); }); @@ -577,6 +602,29 @@ describe("federation reads of saved objects and their history", () => { }); }); + 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); @@ -955,6 +1003,125 @@ describe("embedded documents and implicit contexts", () => { }); }); + 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#" }], + 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), []); + const hidden: SavedResource[] = []; + for (const quoted of [ + { type: "Note", content: "secret", to: [bob], bcc: [blind] }, + { + type: "Note", + content: "secret", + to: { id: `${actorIri}/followers` }, + }, + { "@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/notes/${index}`, + })), + { id: note.iri, type: "Note", content: "x" }, + ]), + "activity", + ); + assert.equal((await get(harness.federation, quoting.iri)).status, 200); + const deletion = await remove(env, note.id); + assert.equal((await get(harness.federation, quoting.iri)).status, 404); + assert.deepEqual(await outboxIds(harness.federation), [ + deletion.iri, + open.iri, + ]); + }); + }); + it("serves a document saved without a context with the ActivityStreams one", async () => { await withFederation(async (harness) => { const env = await setUp(harness); @@ -1147,8 +1314,9 @@ describe("outboxes of saved and legacy activities", () => { like("https://remote.example/n"), "activity", ); - // More hidden activities than a page holds, between served ones. - for (let index = 0; index < 21; index += 1) { + // 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] }), diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index 0cc503c7..a40dc530 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -16,6 +16,7 @@ import { type DocumentNode, + type ExtensionNode, type FieldPath, type JsonValue, addressingProperties, @@ -37,13 +38,16 @@ const recipientProperties: ReadonlySet = new Set(addressingProperties); const pathKey = (path: FieldPath): string => JSON.stringify(path); +/** What serving reads of a described document or extension node object. */ +type Described = Pick; + /** - * Collects each described document's blind addressing keys, whatever their - * spelling, by the document's source path. + * 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 DocumentNode[], + nodes: readonly Pick[], ): ReadonlyMap> => new Map( nodes.map((node) => [ @@ -101,33 +105,39 @@ const isChild = (child: FieldPath, parent: FieldPath): boolean => * its own values only. * @returns The IRI with a compact IRI expanded, or `null` when there is none. */ -const ownIdentifier = (node: DocumentNode, property: string): string | null => +const ownIdentifier = (node: Described, property: string): string | null => node.identifiers.find((entry) => entry.property === property)?.iri ?? null; +/** A document or node object that another one embeds, as a recipient. */ +interface Embedded { + readonly path: FieldPath; + readonly property?: string | undefined; + /** The IRI the addressing index records for it as a recipient. */ + readonly iri: string | null; +} + /** * 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 embedded The documents or node objects that may embed recipients. * @returns The addressing property and IRI of each recipient. */ const recipients = ( - node: DocumentNode, - nodes: readonly DocumentNode[], + node: Described, + embedded: readonly Embedded[], ): { readonly property: string; readonly iri: string | null }[] => [ ...node.identifiers .filter(({ property }) => recipientProperties.has(property)) .map(({ property, iri }) => ({ property, iri })), - ...nodes + ...embedded .filter( (child) => child.property != null && recipientProperties.has(child.property) && isChild(child.path, node.path), ) - .map((child) => ({ - property: child.property!, - iri: ownIdentifier(child, child.kind === "link" ? "href" : "id"), - })), + .map((child) => ({ property: child.property!, iri: child.iri })), ]; /** @@ -137,10 +147,10 @@ const recipients = ( * @returns Whether the document is restricted. */ const isRestricted = ( - node: DocumentNode, - nodes: readonly DocumentNode[], + node: Described, + embedded: readonly Embedded[], ): boolean => { - const named = recipients(node, nodes); + const named = recipients(node, embedded); return ( named.length > 0 && !named.some( @@ -150,7 +160,30 @@ const isRestricted = ( ); }; -const ownId = (node: DocumentNode): string | null => ownIdentifier(node, "id"); +const ownId = (node: Described): string | null => ownIdentifier(node, "id"); + +/** + * Reads the IRIs an activity's `object` refers to without embedding them, as + * references or by the `href` of `Link` values. + * @returns The IRIs. + */ +const referredObjects = ( + node: DocumentNode, + nodes: readonly DocumentNode[], +): string[] => [ + ...node.identifiers + .filter(({ property }) => property === "object") + .map(({ iri }) => iri), + ...nodes + .filter( + (child) => + child.property === "object" && + child.kind === "link" && + isChild(child.path, node.path), + ) + .map((child) => ownIdentifier(child, "href")) + .filter((iri) => iri != null), +]; /** A saved document as it is served, and what its validator reading says. */ export interface ServedDocument { @@ -168,19 +201,28 @@ export interface ServedDocument { /** What serving a saved document depends on besides the document itself. */ export interface DocumentInspection { readonly served: ServedDocument; - /** The `id` of every embedded document, at any depth. */ + /** + * The `id` of every embedded document and extension node object, at any + * depth. + */ readonly embedded: readonly string[]; /** - * The IRIs the root's `object` refers to without embedding them, as - * references or `Link` values. + * The IRIs that the `object` of the root or of an embedded activity, unless + * it is a `Delete`, refers to without embedding them, as references or + * `Link` values. */ readonly objectReferences: readonly string[]; /** - * Whether an embedded document, at any depth, is addressed to recipients - * but not to the public, so that serving the root publicly would expose - * it to others. + * 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 an extension node object has a `@context` of its own, which the + * validator does not read, so that its blind recipients cannot be found. + */ + readonly uninterpreted: boolean; } /** @@ -198,31 +240,43 @@ export function inspectDocument( const embedded = nodes.filter( ({ path, kind }) => path.length > 0 && kind !== "link", ); + const documents = nodes.map( + (node): Embedded => ({ + path: node.path, + property: node.property, + iri: ownIdentifier(node, node.kind === "link" ? "href" : "id"), + }), + ); + const extensions = nodes.flatMap((node) => node.extensions); + const extensionValues = extensions.map( + (node): Embedded => ({ + path: node.path, + property: node.property, + iri: ownId(node) ?? ownIdentifier(node, "href"), + }), + ); return { served: { type: root.type, id: ownId(root), document: statedContext( - omitKeys(document, [], blindKeys(nodes)) as JsonObject, + omitKeys( + document, + [], + blindKeys([...nodes, ...extensions]), + ) as JsonObject, ), }, - embedded: embedded.map(ownId).filter((iri) => iri != null), - objectReferences: [ - ...root.identifiers - .filter(({ property }) => property === "object") - .map(({ iri }) => iri), - // A Link names the object it refers to by its href. - ...nodes - .filter( - (node) => - node.property === "object" && - node.kind === "link" && - isChild(node.path, root.path), - ) - .map((node) => ownIdentifier(node, "href")) - .filter((iri) => iri != null), - ], - restricted: embedded.some((node) => isRestricted(node, nodes)), + embedded: [...embedded, ...extensions] + .map(ownId) + .filter((iri) => iri != null), + objectReferences: nodes + .filter(({ kind, type }) => kind === "activity" && type !== deleteType) + .flatMap((node) => referredObjects(node, nodes)), + restricted: + embedded.some((node) => isRestricted(node, documents)) || + extensions.some((node) => isRestricted(node, extensionValues)), + uninterpreted: extensions.some(({ uninterpreted }) => uninterpreted), }; } @@ -230,25 +284,31 @@ export function inspectDocument( * 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, which is then not served. + * document or its blind recipients cannot be found, so that it is + * not served. */ export function serveDocument(document: JsonObject): ServedDocument | null { - return inspectDocument(document)?.served ?? 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 and of every embedded document, - * found by meaning, so that aliases, full-IRI keys and compact IRI keys are - * removed as well. Opaque extension values are not descended into, since - * the validator reads no addressing in them. 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. + * 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, which is then not served. + * 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; diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index 9036e77a..3ac68dc6 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -43,7 +43,6 @@ import { type DocumentInspection, type ServedDocument, createType, - deleteType, inspectDocument, } from "./document.ts"; import { documentActivity, documentObject } from "./vocab.ts"; @@ -68,9 +67,32 @@ 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 lookupSize = 1000; +// Outbox pages read past hidden activities in batches that grow up to this. +const maximumBatchSize = 1000; +// The outbox counter reads the documents of this many activities at a time. +const countBatchSize = 100; + +/** + * 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. It is bounded by clearing it. +const loggedRevisions = new Set(); +const loggedRevisionLimit = 1024; + function isJsonObject(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -89,20 +111,27 @@ function inspectRevision(row: RevisionRow): DocumentInspection | null { if (inspection != null && inspection.served.id === row.iri) { return inspection; } - logger.error( - "The saved document of {iri} cannot be served; leaving it out.", - { - iri: row.iri, - }, - ); + const key = row.revisionId ?? row.iri; + if (!loggedRevisions.has(key)) { + if (loggedRevisions.size >= loggedRevisionLimit) loggedRevisions.clear(); + loggedRevisions.add(key); + 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 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 whose `object` - * refers to a deleted local object is served only when it is a `Delete`. + * 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 the document is served. @@ -110,33 +139,35 @@ function inspectRevision(row: RevisionRow): DocumentInspection | null { function isServable( inspection: DocumentInspection, deleted: ReadonlySet, - kind: "activity" | "object", ): boolean { - if (inspection.restricted) return false; - if (inspection.embedded.some((iri) => deleted.has(iri))) return false; return ( - kind === "object" || - inspection.served.type === deleteType || - !inspection.objectReferences.some((iri) => deleted.has(iri)) + !inspection.restricted && + !inspection.uninterpreted && + ![...inspection.embedded, ...inspection.objectReferences].some((iri) => + deleted.has(iri), + ) ); } /** - * Finds the deleted local objects among IRIs. + * Finds the deleted local objects among IRIs, in lookups of a bounded size. * @returns The IRIs of those objects. */ async function deletedObjects( db: Database, iris: readonly string[], ): Promise> { - if (iris.length === 0) return new Set(); - const rows = await db - .select({ iri: resources.iri }) - .from(resources) - .where( - and(inArray(resources.iri, [...iris]), deletedLocalObject(resources.id)), - ); - return new Set(rows.map(({ iri }) => iri)); + const rows = await Promise.all( + chunks(iris, lookupSize).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)); } /** @@ -148,7 +179,6 @@ async function deletedObjects( async function servedRevisions( db: Database, rows: readonly RevisionRow[], - kind: "activity" | "object", ): Promise<(ServedDocument | null)[]> { const inspections = rows.map(inspectRevision); const deleted = await deletedObjects(db, [ @@ -161,7 +191,7 @@ async function servedRevisions( ), ]); return inspections.map((inspection) => - inspection != null && isServable(inspection, deleted, kind) + inspection != null && isServable(inspection, deleted) ? inspection.served : null, ); @@ -185,6 +215,7 @@ export async function findServedActivity( .select({ iri: resources.iri, typed: sql`${activities.id} is not null`, + revisionId: resourceRevisions.id, document: resourceRevisions.document, }) .from(resources) @@ -211,7 +242,7 @@ export async function findServedActivity( }); return activity == null ? null : toCreate(ctx, activity); } - const [served] = await servedRevisions(db, [row], "activity"); + const [served] = await servedRevisions(db, [row]); if (served == null || (served.type === createType) !== (route === "create")) { return null; } @@ -260,6 +291,7 @@ export async function findServedObject( .select({ iri: resources.iri, kind: resourceRevisions.kind, + revisionId: resourceRevisions.id, document: resourceRevisions.document, deleted: resourceRevisions.deleted, }) @@ -282,7 +314,7 @@ export async function findServedObject( return new Tombstone({ id: new URL(row.iri), deleted: row.deleted }); } if (row.kind !== "snapshot") return null; - const [served] = await servedRevisions(db, [row], "object"); + const [served] = await servedRevisions(db, [row]); return served == null ? null : documentObject(served); } @@ -321,6 +353,7 @@ async function loadOutboxBatch( position: collectionItems.position, iri: resources.iri, typed: sql`${activities.id} is not null`, + revisionId: resourceRevisions.id, document: resourceRevisions.document, }) .from(collectionItems) @@ -353,7 +386,7 @@ async function loadOutboxBatch( const byId = new Map(typed.map((activity) => [activity.id, activity])); const saved = rows.filter((row) => !row.typed); const served = new Map( - (await servedRevisions(db, saved, "activity")).map((document, index) => [ + (await servedRevisions(db, saved)).map((document, index) => [ saved[index]!.id, document, ]), @@ -380,9 +413,10 @@ async function loadOutboxBatch( * 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 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. + * 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. */ @@ -396,16 +430,18 @@ export async function loadOutboxPage( 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, limit + 1); + 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 <= limit) break; + if (batch.length < size) break; cursor = batch.at(-1)!.position; + if (size < maximumBatchSize) size = Math.min(size * 2, maximumBatchSize); } const page = items.slice(0, limit); return { @@ -416,8 +452,9 @@ export async function loadOutboxPage( /** * Counts the served activities in an outbox, by the same rules as - * {@link loadOutboxPage}. The documents of its saved activities are read, - * since only they tell what those activities embed and refer to. + * {@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( @@ -429,24 +466,44 @@ export async function countOutbox( .from(collectionItems) .innerJoin(activities, eq(activities.id, collectionItems.itemId)) .where(outboxItems(outboxId)); - const saved = await db - .select({ iri: resources.iri, 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))); - const served = await servedRevisions(db, saved, "activity"); - return ( - (legacy?.count ?? 0) + served.filter((document) => document != null).length - ); + 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(countBatchSize); + // oxlint-disable-next-line no-await-in-loop + const served = await servedRevisions(db, saved); + count += served.filter((document) => document != null).length; + if (saved.length < countBatchSize) return count; + after = saved.at(-1)!.id; + } } /** A saved activity's current document, as #145 dispatches it. */ diff --git a/packages/models/README.md b/packages/models/README.md index 03b61995..fc8b7d5e 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -80,12 +80,20 @@ 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, mapped whatever their type coercion. 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. ~~~~ ts import { describeDocument } from "@drfed/models/document"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index eb9ae7ed..10075c12 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4257,6 +4257,75 @@ 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("returns null unless validation completes without errors", () => { assert.equal(describeDocument({ ...like, actor: false }), null); assert.equal(describeDocument({ ...like, type: "Place" }), null); diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 43d69a5c..68f8585f 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1581,6 +1581,24 @@ function checkLinkProperty( } } +// Objects with these keywords are values or lists, not JSON-LD node objects. +const valueObjectKeywords = ["@value", "@list", "@set"] as const; + +function isNodeObject(values: Record): boolean { + return !valueObjectKeywords.some((keyword) => Object.hasOwn(values, keyword)); +} + +// 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( @@ -1663,6 +1681,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 +1693,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 +1716,30 @@ 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 jsonLiteral = keywords && checkExtensionKeywords(values, path, report); + const own = extensionContext(values, context); + if (keywords && own !== undefined && isNodeObject(values)) { + onExtensionNode?.(values, path, own); + } for (const key of Object.keys(values)) { const childKeywords = keywords && !(jsonLiteral && key === "@value"); + // A context definition holds no data. + const childContext = key === "@context" ? undefined : own; children.push(() => - checkExtension(values[key], [...path, key], childKeywords), + checkExtension( + values[key], + [...path, key], + childKeywords, + childContext, + ), ); } } @@ -1822,7 +1861,7 @@ function checkDocumentValue( fieldPath, "This custom property's data is preserved; its semantics were not checked.", ); - checkExtension(value, fieldPath); + checkExtension(value, fieldPath, true, context); return; } report( @@ -2433,6 +2472,41 @@ 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; + /** + * Whether the node object, or one around it in the extension value, has a + * `@context` of its own. The checker does not read such a context, so its + * keys are not interpreted: it has no properties or identifiers. + */ + readonly uninterpreted: boolean; + /** Standard property names mapped to the source keys that supply them. */ + readonly properties: ReadonlyMap; + /** + * String IRI references held by the node object's own standard + * properties, with compact IRIs expanded through the context's prefixes. + */ + readonly identifiers: readonly DocumentIdentifier[]; } /** One IRI reference as written and as the context resolves it. */ @@ -2494,11 +2568,73 @@ function nodeKind(Type: VocabularyType): DocumentNodeKind { 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 (!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 { properties, identifiers }; +} + +// 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 +2649,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 +2662,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 +2676,59 @@ 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 { + const extensionNodes = nameEmbeddingProperties( + extensions.map( + ({ values, path, context }): ExtensionNode => + context == null + ? { + path, + uninterpreted: true, + properties: new Map(), + identifiers: [], + } + : { + path, + uninterpreted: false, + ...describeProperties( + values, + path, + Object.keys(values) + .sort() + .map((source) => resolveProperty(source, context)), + context, + ), + }, + ), + ); + 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( + visited.map( + ({ values, path, context, Type, fields }): DocumentNode => ({ path, type: Type.typeId.href, kind: nodeKind(Type), - properties, - identifiers, - }; - }, - ); - const byPath = new Map( - nodes.map((node) => [JSON.stringify(node.path), node]), + ...describeProperties( + values, + path, + fields.filter(({ compatible }) => compatible), + context, + ), + extensions: owners.get(JSON.stringify(path)) ?? [], + }), + ), ); - 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; - }); } From f9db83c3c16d342c10c17e39f5dbb1bea9005451 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 15:34:42 +0900 Subject: [PATCH 04/25] Read extension node objects as a JSON-LD processor does The second review round found ways around the reading of node objects in extension values: - A key was matched to a standard property only when its container matched too, so a bcc alias with a @list container kept its blind recipients. Keys of extension node objects are now matched by IRI alone. - Recipients and node objects wrapped in @set or @list objects were not read, so a Note addressed to {"@set": [bob]} was served. Values are now read through those objects, and a node object records the node object holding it as its parent. A value object where a reference belongs makes the node object uninterpreted, and its document is not served. - A JSON literal spelled with the type alias of @type was read as nested JSON-LD, so its contents lost keys named bcc. Keyword aliases are now resolved when telling literals and value objects apart. - The object of an activity inside an extension value was not checked against deleted objects. Extension node objects now carry their types, and the object of every one but a Delete is checked. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the second round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests, documentation and changelog entry. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- CHANGES.md | 4 +- changes.d/javascript/federation-serving.md | 4 +- docs/internals/federation-reads.md | 16 +- packages/federation/src/serving.test.ts | 45 +++- packages/federation/src/serving/document.ts | 123 ++++++----- packages/models/README.md | 9 +- packages/models/src/document.test.ts | 50 +++++ packages/models/src/document.ts | 225 +++++++++++++++++--- 8 files changed, 374 insertions(+), 102 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a5ca7e13..bbbd7f78 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -91,8 +91,8 @@ To be released. [[#153] 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 and - their IRI references. + extension values, with the standard properties their keys stand for, + their types and their IRI references. [[#153] by ChanHaeng Lee\] - Added `validateDocument()` in `@drfed/models/document` for parsed ActivityStreams documents. It returns structured errors and compatibility diff --git a/changes.d/javascript/federation-serving.md b/changes.d/javascript/federation-serving.md index 4a750d2a..3998a657 100644 --- a/changes.d/javascript/federation-serving.md +++ b/changes.d/javascript/federation-serving.md @@ -10,6 +10,6 @@ links: - 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 and - their IRI references. + extension values, with the standard properties their keys stand for, + their types and their IRI references. [[#153] by ChanHaeng Lee] diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 75d0bdbd..70f5b7f3 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -42,7 +42,8 @@ is served from the document of its current revision: 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. JSON literals stay as saved. + 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. @@ -84,11 +85,14 @@ actor, and Public `to` or `cc` addressing in the resource's current 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 two rules read the node objects in extension values as embedded - documents, with recipients by `id` or `href`. A saved document with a - node object in an extension value that has a `@context` of its own is - not served, since the validator does not read that context and its - blind recipients cannot be found. + - These rules read the node objects in extension values as embedded + documents, with their values through `@set` and `@list` objects and + their recipients by `id` or `href`. A node object whose `object` + refers to a deleted local object hides the document unless its type is + `Delete`. A saved document is not served when a node object in an + extension value has a `@context` of its own, which the validator does + not read, or a value object where a reference belongs, since its + recipients cannot then be found. - 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 3d4cbaff..9ba46e1f 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -345,6 +345,30 @@ describe("publicDocument()", () => { 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"] } }; + assert.deepEqual( + publicDocument({ + ...document, + "@context": listed, + mood: { hidden: [blind], typed }, + }), + { + "@context": listed, + id: "https://a.example/activities/1", + type: "Like", + actor: actorIri, + object: "https://remote.example/notes/1", + mood: { typed }, + }, + ); // The validator does not read a context inside an extension value. assert.equal( publicDocument({ @@ -1089,6 +1113,9 @@ describe("embedded documents and implicit contexts", () => { content: "secret", to: { id: `${actorIri}/followers` }, }, + { type: "Note", content: "secret", to: { "@set": [bob] } }, + { type: "Note", content: "secret", to: { "@set": [{ id: bob }] } }, + { type: "Note", content: "secret", to: { "@value": PUBLIC_IRI } }, { "@context": { secret: "as:bcc" }, content: "x", secret: [blind] }, ]) { hidden.push(await save(env, like(quoted), "activity")); @@ -1112,11 +1139,27 @@ describe("embedded documents and implicit contexts", () => { ]), "activity", ); - assert.equal((await get(harness.federation, quoting.iri)).status, 200); + // 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: actorIri, object: note.iri }), + "activity", + ); + for (const saved of [quoting, liking, deleting]) { + 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, deleting.iri)).status, 200); assert.deepEqual(await outboxIds(harness.federation), [ deletion.iri, + deleting.iri, open.iri, ]); }); diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index a40dc530..19f7604a 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -90,14 +90,11 @@ const statedContext = (document: JsonObject): JsonObject => ? document : { "@context": activityStreams, ...document }; -// An embedded document sits at [...parent, key] or [...parent, key, index]; -// one at [...parent, key, key] is embedded in another embedded document. -const isChild = (child: FieldPath, parent: FieldPath): boolean => - parent.every((step, index) => child[index] === step) && - typeof child[parent.length] === "string" && - (child.length === parent.length + 1 || - (child.length === parent.length + 2 && - typeof child[parent.length + 1] === "number")); +// 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` @@ -108,34 +105,60 @@ const isChild = (child: FieldPath, parent: FieldPath): boolean => const ownIdentifier = (node: Described, property: string): string | null => node.identifiers.find((entry) => entry.property === property)?.iri ?? null; -/** A document or node object that another one embeds, as a recipient. */ +const ownId = (node: Described): string | null => ownIdentifier(node, "id"); + +/** A document or node object as the one that embeds it reads it. */ interface Embedded { - readonly path: FieldPath; + /** 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 without an id that has an href refers to it, as a Link does. +const embeddedExtension = (node: ExtensionNode): Embedded => { + const id = ownId(node); + const href = id == null ? ownIdentifier(node, "href") : null; + return { + parent: node.parent == null ? null : pathKey(node.parent), + property: node.property, + iri: id ?? href, + 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 embedded The documents or node objects that may embed recipients. + * @param children The documents or node objects it embeds. * @returns The addressing property and IRI of each recipient. */ const recipients = ( node: Described, - embedded: readonly Embedded[], + children: readonly Embedded[], ): { readonly property: string; readonly iri: string | null }[] => [ ...node.identifiers .filter(({ property }) => recipientProperties.has(property)) .map(({ property, iri }) => ({ property, iri })), - ...embedded + ...children .filter( (child) => - child.property != null && - recipientProperties.has(child.property) && - isChild(child.path, node.path), + child.property != null && recipientProperties.has(child.property), ) .map((child) => ({ property: child.property!, iri: child.iri })), ]; @@ -144,13 +167,14 @@ const recipients = ( * 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, - embedded: readonly Embedded[], + children: readonly Embedded[], ): boolean => { - const named = recipients(node, embedded); + const named = recipients(node, children); return ( named.length > 0 && !named.some( @@ -160,28 +184,22 @@ const isRestricted = ( ); }; -const ownId = (node: Described): string | null => ownIdentifier(node, "id"); - /** * Reads the IRIs an activity's `object` refers to without embedding them, as * references or by the `href` of `Link` values. + * @param children The documents or node objects it embeds. * @returns The IRIs. */ const referredObjects = ( - node: DocumentNode, - nodes: readonly DocumentNode[], + node: Described, + children: readonly Embedded[], ): string[] => [ ...node.identifiers .filter(({ property }) => property === "object") .map(({ iri }) => iri), - ...nodes - .filter( - (child) => - child.property === "object" && - child.kind === "link" && - isChild(child.path, node.path), - ) - .map((child) => ownIdentifier(child, "href")) + ...children + .filter((child) => child.property === "object") + .map(({ href }) => href) .filter((iri) => iri != null), ]; @@ -207,9 +225,9 @@ export interface DocumentInspection { */ readonly embedded: readonly string[]; /** - * The IRIs that the `object` of the root or of an embedded activity, unless - * it is a `Delete`, refers to without embedding them, as references or - * `Link` values. + * 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[]; /** @@ -219,8 +237,9 @@ export interface DocumentInspection { */ readonly restricted: boolean; /** - * Whether an extension node object has a `@context` of its own, which the - * validator does not read, so that its blind recipients cannot be found. + * Whether the validator cannot read all of an extension node object, as + * when it has a `@context` of its own, so that its blind recipients cannot + * be found. */ readonly uninterpreted: boolean; } @@ -240,21 +259,13 @@ export function inspectDocument( const embedded = nodes.filter( ({ path, kind }) => path.length > 0 && kind !== "link", ); - const documents = nodes.map( - (node): Embedded => ({ - path: node.path, - property: node.property, - iri: ownIdentifier(node, node.kind === "link" ? "href" : "id"), - }), - ); const extensions = nodes.flatMap((node) => node.extensions); - const extensionValues = extensions.map( - (node): Embedded => ({ - path: node.path, - property: node.property, - iri: ownId(node) ?? ownIdentifier(node, "href"), - }), + const byParent = Map.groupBy( + [...nodes.map(embeddedDocument), ...extensions.map(embeddedExtension)], + ({ parent }) => parent, ); + const children = (node: Described): readonly Embedded[] => + byParent.get(pathKey(node.path)) ?? []; return { served: { type: root.type, @@ -270,12 +281,16 @@ export function inspectDocument( embedded: [...embedded, ...extensions] .map(ownId) .filter((iri) => iri != null), - objectReferences: nodes - .filter(({ kind, type }) => kind === "activity" && type !== deleteType) - .flatMap((node) => referredObjects(node, nodes)), - restricted: - embedded.some((node) => isRestricted(node, documents)) || - extensions.some((node) => isRestricted(node, extensionValues)), + // A node object in an extension value has no kind the validator reads. + objectReferences: [ + ...nodes.filter( + ({ kind, type }) => kind === "activity" && type !== deleteType, + ), + ...extensions.filter(({ types }) => !types.includes(deleteType)), + ].flatMap((node) => referredObjects(node, children(node))), + restricted: [...embedded, ...extensions].some((node) => + isRestricted(node, children(node)), + ), uninterpreted: extensions.some(({ uninterpreted }) => uninterpreted), }; } diff --git a/packages/models/README.md b/packages/models/README.md index fc8b7d5e..199fd61a 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -91,9 +91,12 @@ 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, mapped whatever their type coercion. 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. +IRI references as a node has, and the `parent` node object and `types` too. +Keys are matched by IRI whatever their type coercion or 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 with a value object where a +reference belongs. ~~~~ ts import { describeDocument } from "@drfed/models/document"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 10075c12..b97967e9 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4326,6 +4326,56 @@ describe("describeDocument() semantic view", () => { 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); + }); + it("returns null unless validation completes without errors", () => { assert.equal(describeDocument({ ...like, actor: false }), null); assert.equal(describeDocument({ ...like, type: "Place" }), null); diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 68f8585f..3e04476e 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1582,10 +1582,65 @@ function checkLinkProperty( } // Objects with these keywords are values or lists, not JSON-LD node objects. -const valueObjectKeywords = ["@value", "@list", "@set"] as const; +const valueObjectKeywords: ReadonlySet = new Set([ + "@value", + "@list", + "@set", +]); + +// 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, +): 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) => + valueObjectKeywords.has(keywordOf(key, context) ?? ""), + ); +} -function isNodeObject(values: Record): boolean { - return !valueObjectKeywords.some((keyword) => Object.hasOwn(values, keyword)); +// Whether a value object holds a JSON literal, however its keywords are +// spelled. +function isJsonLiteral( + values: Record, + context: DocumentContext | null, +): boolean { + return Object.keys(values).some((key) => { + if (keywordOf(key, context) !== "@type") return false; + const type = values[key]; + return ( + type === "@json" || + (Array.isArray(type) && type.length === 1 && type[0] === "@json") + ); + }); +} + +// The context a value of an extension object is described with: none for a +// context definition or the value of a JSON literal, which hold no node +// objects. +function extensionChildContext( + values: Record, + key: string, + context: DocumentContext | null | undefined, +): DocumentContext | null | undefined { + if (key === "@context") return undefined; + if ( + context !== undefined && + keywordOf(key, context) === "@value" && + isJsonLiteral(values, context) + ) { + return undefined; + } + return context; } // The context an extension object is read with: none when it is not @@ -1726,19 +1781,17 @@ function checkDocumentValue( const jsonLiteral = keywords && checkExtensionKeywords(values, path, report); const own = extensionContext(values, context); - if (keywords && own !== undefined && isNodeObject(values)) { + if (keywords && own !== undefined && isNodeObject(values, own)) { onExtensionNode?.(values, path, own); } for (const key of Object.keys(values)) { const childKeywords = keywords && !(jsonLiteral && key === "@value"); - // A context definition holds no data. - const childContext = key === "@context" ? undefined : own; children.push(() => checkExtension( values[key], [...path, key], childKeywords, - childContext, + extensionChildContext(values, key, own), ), ); } @@ -2494,19 +2547,29 @@ export interface ExtensionNode { * property. */ readonly property?: string; + /** The path of the node object whose `property` holds this one. */ + readonly parent?: FieldPath; /** - * Whether the node object, or one around it in the extension value, has a - * `@context` of its own. The checker does not read such a context, so its - * keys are not interpreted: it has no properties or identifiers. + * 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 a value object. 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. */ + /** + * 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, with compact IRIs expanded through the context's prefixes. + * 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[]; } /** One IRI reference as written and as the context resolves it. */ @@ -2601,6 +2664,107 @@ function describeProperties( return { properties, identifiers }; } +// The standard name of an extension node object's key, matched by IRI +// whatever its type coercion or container, as a JSON-LD processor reads it. +function extensionPropertyName( + source: string, + context: DocumentContext, +): string | undefined { + const { name, iri } = resolveProperty(source, context); + if (name != null || iri == null) return name; + for (const [standard, term] of getContextTables().standardTerms) { + if (term.iri === iri) return standard; + } + return undefined; +} + +// The entries of a value, through arrays and @set or @list objects. +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; + 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]]; +} + +// 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 identifiers: DocumentIdentifier[] = []; + const types: string[] = []; + const children: [FieldPath, string][] = []; + let uninterpreted = false; + for (const source of Object.keys(values).sort()) { + const name = extensionPropertyName(source, context); + if (name == null) continue; + properties.set(name, [...(properties.get(name) ?? []), source]); + for (const [entry, entryPath] of valueEntries( + values[source], + [...path, source], + context, + )) { + if (typeof entry === "string") { + if (name === "type") { + types.push( + context.terms.get(entry)?.iri ?? expandIdentifier(entry, context), + ); + } else if (identifierProperties.has(name)) { + identifiers.push({ + property: name, + path: entryPath, + value: entry, + iri: expandIdentifier(entry, context), + }); + } + } else if (entry !== null && typeof entry === "object") { + const object = entry as Record; + if (isNodeObject(object, context)) children.push([entryPath, name]); + // A literal is no IRI reference the checker can read. + else if (identifierProperties.has(name)) uninterpreted = true; + } + } + } + return { + node: { path, uninterpreted, properties, identifiers, types }, + children, + }; +} + // Name the property of its parent that embeds each value, which sits at // [...parent, key] or [...parent, key, index]. function nameEmbeddingProperties< @@ -2676,30 +2840,23 @@ export function describeDocument( } // Read values only now: validation has rejected sparse arrays, accessors, // and other non-JSON shapes without evaluating them. - const extensionNodes = nameEmbeddingProperties( - extensions.map( - ({ values, path, context }): ExtensionNode => - context == null - ? { - path, - uninterpreted: true, - properties: new Map(), - identifiers: [], - } - : { - path, - uninterpreted: false, - ...describeProperties( - values, - path, - Object.keys(values) - .sort() - .map((source) => resolveProperty(source, context)), - context, - ), - }, + const described = extensions.map(({ values, path, context }) => + describeExtensionNode(values, path, context), + ); + const holders = new Map( + described.flatMap(({ node, children }) => + children.map(([path, property]) => [ + JSON.stringify(path), + { property, parent: node.path }, + ]), ), ); + const extensionNodes = described.map( + ({ node }): ExtensionNode => ({ + ...node, + ...holders.get(JSON.stringify(node.path)), + }), + ); const owners = new Map( visited.map(({ path }): [string, ExtensionNode[]] => [ JSON.stringify(path), From f80764d5df7f3cbe75f430b1dff26abe50e9d5c0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 15:58:16 +0900 Subject: [PATCH 05/25] Refuse extension node objects whose references are unread The third review round found references in extension node objects that were read differently from how a JSON-LD processor reads them: - A Link with both an id and an href was read by its id, so a Note addressed to {"type": "Link", "id": Public, "href": bob} was served, and a Link to a deleted object escaped the deletion rule. Extension node objects now carry the kind of their known types; a Link is read as a recipient by its href, and every href is checked as a reference. - A string under a key that does not coerce it to an IRI, such as a compact as:to key, is a literal, not a recipient, but counted as one. A relative reference, such as an id of /notes/1, was compared with absolute IRIs, so the deletion rule missed it. A @set or @list object with a @context of its own was read with the outer context. A reference property holding any of these now leaves its node object uninterpreted, and its document is not served. - Validation still read the payload of a JSON literal spelled with the type alias of @type as JSON-LD keywords, so a payload with an @id of 1 refused the document. Validation and description now tell JSON literals apart in the same way. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the third round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 15 ++- packages/federation/src/serving.test.ts | 35 +++++- packages/federation/src/serving/document.ts | 9 +- packages/models/README.md | 21 ++-- packages/models/src/document.test.ts | 39 ++++++ packages/models/src/document.ts | 127 +++++++++++++------- 6 files changed, 182 insertions(+), 64 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 70f5b7f3..2808cd9a 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -87,12 +87,15 @@ actor, and Public `to` or `cc` addressing in the resource's current 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 `id` or `href`. A node object whose `object` - refers to a deleted local object hides the document unless its type is - `Delete`. A saved document is not served when a node object in an - extension value has a `@context` of its own, which the validator does - not read, or a value object where a reference belongs, since its - recipients cannot then be found. + their recipients by `href` when a type is `Link`, and by `id` or else + `href` 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 string its key does not coerce to an IRI, or a + relative reference where a reference belongs, since its recipients and + references cannot then be told. - 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 9ba46e1f..aec2c7c1 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -353,7 +353,7 @@ describe("publicDocument()", () => { hidden: { "@id": "as:bcc", "@type": "@id", "@container": "@list" }, }, ]; - const typed = { type: "@json", "@value": { bcc: ["kept"] } }; + const typed = { type: "@json", "@value": { bcc: ["kept"], "@id": 1 } }; assert.deepEqual( publicDocument({ ...document, @@ -1116,6 +1116,23 @@ describe("embedded documents and implicit contexts", () => { { 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 relative reference names no resource that can be checked. + { type: "Note", id: "/notes/1", content: "secret" }, { "@context": { secret: "as:bcc" }, content: "x", secret: [blind] }, ]) { hidden.push(await save(env, like(quoted), "activity")); @@ -1150,12 +1167,26 @@ describe("embedded documents and implicit contexts", () => { like({ type: "Delete", actor: actorIri, object: note.iri }), "activity", ); - for (const saved of [quoting, liking, deleting]) { + 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, 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, deleting.iri)).status, 200); assert.deepEqual(await outboxIds(harness.federation), [ deletion.iri, diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index 19f7604a..cbfb1bb7 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -129,14 +129,15 @@ const embeddedDocument = (node: DocumentNode): Embedded => { }; }; -// A node object without an id that has an href refers to it, as a Link does. +// A node object in an extension value is read as a Link when one of its +// types is, and as a recipient by its id or else its href otherwise. Its +// href is a reference whatever its type, and its id is embedded. const embeddedExtension = (node: ExtensionNode): Embedded => { - const id = ownId(node); - const href = id == null ? ownIdentifier(node, "href") : null; + const href = ownIdentifier(node, "href"); return { parent: node.parent == null ? null : pathKey(node.parent), property: node.property, - iri: id ?? href, + iri: node.kind === "link" ? href : (ownId(node) ?? href), href, }; }; diff --git a/packages/models/README.md b/packages/models/README.md index 199fd61a..0dfdca3b 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -91,12 +91,14 @@ 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 and `types` too. -Keys are matched by IRI whatever their type coercion or 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 with a value object where a -reference belongs. +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 and node objects: a value object, a +string its key does not coerce to an IRI, a relative reference, or a `@set` or +`@list` object with a `@context` of its own. ~~~~ ts import { describeDocument } from "@drfed/models/document"; @@ -322,9 +324,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 b97967e9..ae2bf06e 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4376,6 +4376,45 @@ describe("describeDocument() semantic view", () => { assert.equal(valued!.uninterpreted, true); }); + it("leaves extension node objects uninterpreted where references are unread", () => { + const quotedNode = (quoted: unknown) => + describeDocument({ + ...like, + "@context": [context, { ex: "https://ext.example/ns#", hint: "as:to" }], + "ex:quoted": quoted, + })?.[0]?.extensions[0]; + 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"] }, + }, + ]) { + assert.equal( + quotedNode(quoted)?.uninterpreted, + true, + JSON.stringify(quoted), + ); + } + 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 JSON literal holds data, however its keywords are spelled. + assert.ok( + describeDocument({ + ...like, + custom: { type: "@json", "@value": { "@id": 1, bcc: "x" } }, + })?.[0]?.extensions.length === 0, + ); + }); + it("returns null unless validation completes without errors", () => { assert.equal(describeDocument({ ...like, actor: false }), null); assert.equal(describeDocument({ ...like, type: "Place" }), null); diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 3e04476e..7147d203 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1592,7 +1592,7 @@ const valueObjectKeywords: ReadonlySet = new Set([ // context the checker does not read aliases nothing it knows of. function keywordOf( key: string, - context: DocumentContext | null, + context: DocumentContext | null | undefined, ): string | undefined { if (key.startsWith("@")) return key; const iri = context?.terms.get(key)?.iri; @@ -1612,35 +1612,31 @@ function isNodeObject( // spelled. function isJsonLiteral( values: Record, - context: DocumentContext | null, + 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 && type[0] === "@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 or the value of a JSON literal, which hold no node -// objects. -function extensionChildContext( +// Check the keywords of an extension object, and tell whether it holds a +// JSON literal, however its keywords are spelled. +function checkJsonLiteral( values: Record, - key: string, + path: FieldPath, + report: Report, context: DocumentContext | null | undefined, -): DocumentContext | null | undefined { - if (key === "@context") return undefined; - if ( - context !== undefined && - keywordOf(key, context) === "@value" && - isJsonLiteral(values, context) - ) { - return undefined; - } - return context; +): 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 @@ -1778,20 +1774,22 @@ function checkDocumentValue( } else { const values = readDocument(value, path, report); if (values != null) { - const jsonLiteral = - keywords && checkExtensionKeywords(values, path, report); const own = extensionContext(values, context); + const jsonLiteral = + keywords && checkJsonLiteral(values, path, report, own); if (keywords && own !== undefined && isNodeObject(values, own)) { onExtensionNode?.(values, path, own); } for (const key of Object.keys(values)) { const childKeywords = keywords && !(jsonLiteral && key === "@value"); + // A context definition holds no node objects. + const childContext = key === "@context" ? undefined : own; children.push(() => checkExtension( values[key], [...path, key], childKeywords, - extensionChildContext(values, key, own), + childContext, ), ); } @@ -2552,9 +2550,11 @@ export interface ExtensionNode { /** * 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 a value object. An - * uninterpreted node object has no properties, identifiers or types when - * its context is unread. + * checker does not read, or a reference property holds something other + * than absolute IRIs and node objects, such as a value object, a string + * the property's alias does not coerce to an IRI, or a relative reference. + * An uninterpreted node object has no properties, identifiers or types + * when its context is unread. */ readonly uninterpreted: boolean; /** @@ -2570,6 +2570,11 @@ export interface ExtensionNode { 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. */ @@ -2665,20 +2670,40 @@ function describeProperties( } // The standard name of an extension node object's key, matched by IRI -// whatever its type coercion or container, as a JSON-LD processor reads it. -function extensionPropertyName( +// 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, -): string | undefined { - const { name, iri } = resolveProperty(source, context); - if (name != null || iri == null) return name; +): { 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 standard; + if (term.iri === iri) { + return { name: standard, compatible: term.type === type }; + } } return undefined; } -// The entries of a value, through arrays and @set or @list objects. +// References must be absolute IRIs; relative ones need a base-IRI policy. +const isAbsoluteIri = (value: string): boolean => + /^[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, @@ -2691,6 +2716,7 @@ function valueEntries( } 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"; @@ -2730,37 +2756,52 @@ function describeExtensionNode( const children: [FieldPath, string][] = []; let uninterpreted = false; for (const source of Object.keys(values).sort()) { - const name = extensionPropertyName(source, context); - if (name == null) continue; + const property = extensionProperty(source, context); + if (property == null) continue; + const { name, compatible } = property; properties.set(name, [...(properties.get(name) ?? []), source]); + const reference = identifierProperties.has(name); for (const [entry, entryPath] of valueEntries( values[source], [...path, source], context, )) { - if (typeof entry === "string") { - if (name === "type") { - types.push( - context.terms.get(entry)?.iri ?? expandIdentifier(entry, context), - ); - } else if (identifierProperties.has(name)) { + if (typeof entry === "string" && name === "type") { + types.push( + context.terms.get(entry)?.iri ?? expandIdentifier(entry, context), + ); + } else if (typeof entry === "string" && reference) { + const iri = expandIdentifier(entry, context); + // A literal or a relative reference names no resource the checker + // can tell. + if (compatible && isAbsoluteIri(iri)) { identifiers.push({ property: name, path: entryPath, value: entry, - iri: expandIdentifier(entry, context), + iri, }); + } else { + uninterpreted = true; } } else if (entry !== null && typeof entry === "object") { const object = entry as Record; if (isNodeObject(object, context)) children.push([entryPath, name]); - // A literal is no IRI reference the checker can read. - else if (identifierProperties.has(name)) uninterpreted = true; + else if (reference) uninterpreted = true; } } } + const kinds = types.map(typeKind).filter((kind) => kind != null); + const kind = kinds.includes("link") ? "link" : kinds[0]; return { - node: { path, uninterpreted, properties, identifiers, types }, + node: { + path, + uninterpreted, + properties, + identifiers, + types, + ...(kind == null ? {} : { kind }), + }, children, }; } From a75fdff6898c1c63dbbc08a89945a6b770930db0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 16:22:30 +0900 Subject: [PATCH 06/25] Read blank nodes, language maps and value payloads in extensions The fourth review round found readings of extension node objects that still differed from a JSON-LD processor's: - A blank node identifier, such as an id of _:b0, failed the absolute IRI check and left a document unservable. It is now read as the name of no resource. - A language map under an alias of to with a @language container was read as a node object, so its id language tag counted as a Public recipient. A language map in a reference property, and more than one id or href, or a node object as one, now leave a node object uninterpreted, so that every href of a Link is checked. - A JSON literal whose @type alias came from a @context of its own was not recognized, and its payload refused its document. The value of a value object is a scalar or a JSON literal, so it is no longer read for node objects, however its type is spelled. - A document refused because an extension node object is uninterpreted was not logged. It is now logged once per revision as a warning. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the fourth round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 8 +- packages/federation/src/serving.test.ts | 35 +++++- packages/federation/src/serving/resource.ts | 34 +++-- packages/models/README.md | 8 +- packages/models/src/document.test.ts | 25 +++- packages/models/src/document.ts | 133 ++++++++++++++------ 6 files changed, 188 insertions(+), 55 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 2808cd9a..d23284d7 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -93,9 +93,11 @@ actor, and Public `to` or `cc` addressing in the resource's current 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 string its key does not coerce to an IRI, or a - relative reference where a reference belongs, since its recipients and - references cannot then be told. + a value object, a language map, a string its key does not coerce to an + IRI, or a relative reference where a reference belongs, or more than + one `id` or `href`, since its recipients and references cannot then be + told. Blank node identifiers are read as names of no resource. Such a + document is 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index aec2c7c1..f3797bd6 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1078,7 +1078,13 @@ describe("embedded documents and implicit contexts", () => { await withFederation(async (harness) => { const env = await setUp(harness); const like = (quoted: JsonObject | JsonObject[]): JsonObject => ({ - "@context": [AS, { ex: "https://ext.example/ns#" }], + "@context": [ + AS, + { + ex: "https://ext.example/ns#", + toMap: { "@id": "as:to", "@container": "@language" }, + }, + ], type: "Like", to: [PUBLIC_IRI], object: "https://remote.example/notes/1", @@ -1105,6 +1111,23 @@ describe("embedded documents and implicit contexts", () => { 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 JSON literal read with an alias of + // @type from a context of its own stay readable. + const readable: string[] = []; + for (const quoted of [ + { id: "_:b0", type: "Note", content: "open", to: [PUBLIC_IRI] }, + { + "@context": { kind: "@type" }, + kind: "@json", + "@value": { bcc: "data" }, + }, + ]) { + 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] }, @@ -1133,6 +1156,15 @@ describe("embedded documents and implicit contexts", () => { }, // 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", `${actorIri}/notes/1`], + }, + }, { "@context": { secret: "as:bcc" }, content: "x", secret: [blind] }, ]) { hidden.push(await save(env, like(quoted), "activity")); @@ -1191,6 +1223,7 @@ describe("embedded documents and implicit contexts", () => { assert.deepEqual(await outboxIds(harness.federation), [ deletion.iri, deleting.iri, + ...readable, open.iri, ]); }); diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index 3ac68dc6..20466fd4 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -93,6 +93,14 @@ const chunks = (values: readonly T[], size: number): T[][] => const loggedRevisions = new Set(); const loggedRevisionLimit = 1024; +function logOnce(row: RevisionRow, log: () => void): void { + const key = row.revisionId ?? row.iri; + if (loggedRevisions.has(key)) return; + if (loggedRevisions.size >= loggedRevisionLimit) loggedRevisions.clear(); + loggedRevisions.add(key); + log(); +} + function isJsonObject(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -101,7 +109,9 @@ function isJsonObject(value: unknown): value is JsonObject { * Reads a saved revision's document for serving. 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 and - * left out, so that it takes down nothing but its own resource. + * left out, so that it takes down nothing but its own resource. A document + * whose extension values cannot be read is logged as a warning, and is not + * served either. Each revision is logged once. * @returns The inspection, or `null` when the document cannot be served. */ function inspectRevision(row: RevisionRow): DocumentInspection | null { @@ -109,19 +119,23 @@ function inspectRevision(row: RevisionRow): DocumentInspection | null { ? 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 an extension value whose " + + "addressing cannot be read; it is not served.", + { iri: row.iri }, + ), + ); + } return inspection; } - const key = row.revisionId ?? row.iri; - if (!loggedRevisions.has(key)) { - if (loggedRevisions.size >= loggedRevisionLimit) loggedRevisions.clear(); - loggedRevisions.add(key); + logOnce(row, () => logger.error( "The saved document of {iri} cannot be served; leaving it out.", - { - iri: row.iri, - }, - ); - } + { iri: row.iri }, + ), + ); return null; } diff --git a/packages/models/README.md b/packages/models/README.md index 0dfdca3b..3bab61ad 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -96,9 +96,11 @@ IRI references as a node has, and the `parent` node object, `types` and the 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 and node objects: a value object, a -string its key does not coerce to an IRI, a relative reference, or a `@set` or -`@list` object with a `@context` of its own. +property holds anything but absolute IRIs, blank node identifiers and node +objects: a value object, a language map, a string its key does not coerce to +an IRI, a relative reference, 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. The value of a value object is never read for node objects. ~~~~ ts import { describeDocument } from "@drfed/models/document"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index ae2bf06e..df03008b 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4380,7 +4380,14 @@ describe("describeDocument() semantic view", () => { const quotedNode = (quoted: unknown) => describeDocument({ ...like, - "@context": [context, { ex: "https://ext.example/ns#", hint: "as:to" }], + "@context": [ + context, + { + ex: "https://ext.example/ns#", + hint: "as:to", + toMap: { "@id": "as:to", "@container": "@language" }, + }, + ], "ex:quoted": quoted, })?.[0]?.extensions[0]; for (const quoted of [ @@ -4391,6 +4398,9 @@ describe("describeDocument() semantic view", () => { 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" } }, ]) { assert.equal( quotedNode(quoted)?.uninterpreted, @@ -4406,6 +4416,19 @@ describe("describeDocument() semantic view", () => { assert.equal(link?.uninterpreted, false); assert.equal(link?.kind, "link"); assert.equal(quotedNode({ type: "Note", content: "x" })?.kind, "object"); + // A blank node identifier names no resource, and stays readable. + const blank = quotedNode({ type: "Note", id: "_:b0", to: "as:Public" }); + assert.equal(blank?.uninterpreted, false); + assert.equal(blank?.identifiers[0]?.iri, "_:b0"); + // The value of a value object holds no node objects, whatever its type. + assert.equal( + quotedNode({ + "@context": { kind: "@type" }, + kind: "@json", + "@value": { bcc: "x" }, + }), + undefined, + ); // A JSON literal holds data, however its keywords are spelled. assert.ok( describeDocument({ diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 7147d203..6aaf1f42 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1627,6 +1627,18 @@ function isJsonLiteral( }); } +// The context a value of an extension object is described with: none for a +// context definition, or for the value of a value object, which is a scalar +// or a JSON literal and holds no node objects. +function extensionChildContext( + key: string, + context: DocumentContext | null | undefined, +): DocumentContext | null | undefined { + return key === "@context" || keywordOf(key, context) === "@value" + ? undefined + : context; +} + // Check the keywords of an extension object, and tell whether it holds a // JSON literal, however its keywords are spelled. function checkJsonLiteral( @@ -1782,14 +1794,12 @@ function checkDocumentValue( } for (const key of Object.keys(values)) { const childKeywords = keywords && !(jsonLiteral && key === "@value"); - // A context definition holds no node objects. - const childContext = key === "@context" ? undefined : own; children.push(() => checkExtension( values[key], [...path, key], childKeywords, - childContext, + extensionChildContext(key, own), ), ); } @@ -2551,8 +2561,10 @@ export interface ExtensionNode { * 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 and node objects, such as a value object, a string - * the property's alias does not coerce to an IRI, or a relative reference. + * than absolute IRIs, blank node identifiers and node objects, such as a + * value object, a language map, a string the property's alias does not + * coerce to an IRI, or a relative reference, or more than one `id` or + * `href`, or a node object as one. * An uninterpreted node object has no properties, identifiers or types * when its context is unread. */ @@ -2688,9 +2700,14 @@ function extensionProperty( return undefined; } -// References must be absolute IRIs; relative ones need a base-IRI policy. -const isAbsoluteIri = (value: string): boolean => - /^[a-z][a-z\d+.-]*:/iu.test(value) && URL.canParse(value); +// 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)); + +// A node object has one of each. +const functionalReferences = ["id", "href"] as const; function typeKind(iri: string): DocumentNodeKind | undefined { const Type = @@ -2728,6 +2745,59 @@ function valueEntries( 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 = identifierProperties.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. + if (!compatible || !isReadableReference(iri)) return false; + reading.identifiers.push({ property: name, path, value: entry, iri }); + return true; + } + // 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 functionalReferences.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( @@ -2751,52 +2821,41 @@ function describeExtensionNode( }; } const properties = new Map(); - const identifiers: DocumentIdentifier[] = []; - const types: string[] = []; - const children: [FieldPath, string][] = []; - let uninterpreted = false; + const reading: ExtensionReading = { + identifiers: [], + types: [], + children: [], + }; + let readable = true; for (const source of Object.keys(values).sort()) { const property = extensionProperty(source, context); if (property == null) continue; - const { name, compatible } = property; + const { name } = property; properties.set(name, [...(properties.get(name) ?? []), source]); - const reference = identifierProperties.has(name); + // A language map holds strings in languages, not references. + if (context.terms.get(source)?.container === "@language") { + if (identifierProperties.has(name) && values[source] != null) { + readable = false; + } + continue; + } for (const [entry, entryPath] of valueEntries( values[source], [...path, source], context, )) { - if (typeof entry === "string" && name === "type") { - types.push( - context.terms.get(entry)?.iri ?? expandIdentifier(entry, context), - ); - } else if (typeof entry === "string" && reference) { - const iri = expandIdentifier(entry, context); - // A literal or a relative reference names no resource the checker - // can tell. - if (compatible && isAbsoluteIri(iri)) { - identifiers.push({ - property: name, - path: entryPath, - value: entry, - iri, - }); - } else { - uninterpreted = true; - } - } else if (entry !== null && typeof entry === "object") { - const object = entry as Record; - if (isNodeObject(object, context)) children.push([entryPath, name]); - else if (reference) uninterpreted = true; + 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, + uninterpreted: !readable || hasAmbiguousReference(reading), properties, identifiers, types, From c9d351f3ef3824ed5c9a8c415dd1497eca6815c8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 16:42:18 +0900 Subject: [PATCH 07/25] Keep blank nodes and language maps in extensions as they are The fifth review round found two more readings of extension values that differed from a JSON-LD processor's: - A blank node identifier was expanded as a compact IRI, so with a prefix named _ for ActivityStreams, a recipient of _:Public counted as the public. Blank node identifiers are no longer expanded. - A language map under a custom property with a @language container was read as a node object, so a language tag such as id made an accepted document unservable. The value of a key with a @language container is no longer read for node objects. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the fifth round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 5 ++-- packages/federation/src/serving.test.ts | 10 ++++++++ packages/models/README.md | 4 +++- packages/models/src/document.test.ts | 31 ++++++++++++++++++++++--- packages/models/src/document.ts | 20 ++++++++++++---- 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index d23284d7..a0de532a 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -96,8 +96,9 @@ actor, and Public `to` or `cc` addressing in the resource's current a value object, a language map, a string its key does not coerce to an IRI, or a relative reference where a reference belongs, or more than one `id` or `href`, since its recipients and references cannot then be - told. Blank node identifiers are read as names of no resource. Such a - document is logged once per revision as a warning. + told. Blank node identifiers are read as names of no resource, and + language maps as strings. Such a document is 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index f3797bd6..bd5c652b 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1083,6 +1083,8 @@ describe("embedded documents and implicit contexts", () => { { ex: "https://ext.example/ns#", toMap: { "@id": "as:to", "@container": "@language" }, + labels: { "@id": "ex:labels", "@container": "@language" }, + _: `${AS}#`, }, ], type: "Like", @@ -1116,6 +1118,12 @@ describe("embedded documents and implicit contexts", () => { 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" }, + }, { "@context": { kind: "@type" }, kind: "@json", @@ -1154,6 +1162,8 @@ describe("embedded documents and implicit contexts", () => { "@set": ["as:Public"], }, }, + // A blank node is no compact IRI, whatever prefix "_" names. + { type: "Note", content: "secret", to: [bob, "_:Public"] }, // 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. diff --git a/packages/models/README.md b/packages/models/README.md index 3bab61ad..40d6fef9 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -100,7 +100,9 @@ property holds anything but absolute IRIs, blank node identifiers and node objects: a value object, a language map, a string its key does not coerce to an IRI, a relative reference, 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. The value of a value object is never read for node objects. +one. The value of a value object, or of a key with a `@language` container, is +never read for node objects, and a blank node identifier is not expanded as a +compact IRI. ~~~~ ts import { describeDocument } from "@drfed/models/document"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index df03008b..1b742a17 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4386,6 +4386,8 @@ describe("describeDocument() semantic view", () => { ex: "https://ext.example/ns#", hint: "as:to", toMap: { "@id": "as:to", "@container": "@language" }, + labels: { "@id": "ex:labels", "@container": "@language" }, + _: `${context}#`, }, ], "ex:quoted": quoted, @@ -4416,10 +4418,33 @@ describe("describeDocument() semantic view", () => { assert.equal(link?.uninterpreted, false); assert.equal(link?.kind, "link"); assert.equal(quotedNode({ type: "Note", content: "x" })?.kind, "object"); - // A blank node identifier names no resource, and stays readable. - const blank = quotedNode({ type: "Note", id: "_:b0", to: "as:Public" }); + // A blank node identifier names no resource, and stays readable, but + // is no compact IRI, whatever prefix "_" names. + const blank = quotedNode({ type: "Note", id: "_:b0", to: "_:Public" }); assert.equal(blank?.uninterpreted, false); - assert.equal(blank?.identifiers[0]?.iri, "_:b0"); + assert.deepEqual( + blank?.identifiers.map(({ iri }) => iri), + ["_:b0", "_:Public"], + ); + // A language map holds strings, whatever its language tags. + const labelled = quotedNode({ type: "Note", labels: { id: "Halo" } }); + assert.equal(labelled?.uninterpreted, false); + assert.equal( + describeDocument({ + ...like, + "@context": [ + context, + { + labels: { + "@id": "https://example.com/labels", + "@container": "@language", + }, + }, + ], + labels: { id: "Halo", bcc: "x" }, + })?.[0]?.extensions.length, + 0, + ); // The value of a value object holds no node objects, whatever its type. assert.equal( quotedNode({ diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 6aaf1f42..9c2fc933 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1628,13 +1628,15 @@ function isJsonLiteral( } // The context a value of an extension object is described with: none for a -// context definition, or for the value of a value object, which is a scalar -// or a JSON literal and holds no node objects. +// 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. function extensionChildContext( key: string, context: DocumentContext | null | undefined, ): DocumentContext | null | undefined { - return key === "@context" || keywordOf(key, context) === "@value" + return key === "@context" || + keywordOf(key, context) === "@value" || + context?.terms.get(key)?.container === "@language" ? undefined : context; } @@ -1922,7 +1924,12 @@ function checkDocumentValue( fieldPath, "This custom property's data is preserved; its semantics were not checked.", ); - checkExtension(value, fieldPath, true, context); + checkExtension( + value, + fieldPath, + true, + extensionChildContext(source, context), + ); return; } report( @@ -2772,7 +2779,10 @@ function readExtensionEntry( return true; } if (!reference) return true; - const iri = expandIdentifier(entry, context); + // A blank node identifier is no compact IRI, whatever prefix "_" names. + const iri = entry.startsWith("_:") + ? entry + : expandIdentifier(entry, context); // A literal or a relative reference names no resource the checker can // tell. if (!compatible || !isReadableReference(iri)) return false; From e407188fd1df577603144c42eabdbcc6db2fc08e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 17:05:55 +0900 Subject: [PATCH 08/25] Read blank node types and singleton-list Link recipients as written The sixth review round found these gaps: - A blank node identifier was still expanded where a term or type was read, so with a prefix named _ for ActivityStreams, a type of _:Delete counted as a Delete. JSON-LD never expands _: values; the validator now does not either, so such a type is unsupported in a document, and stays a blank node in an extension value. - A Link recipient whose href was a singleton list, which validation accepts, was left out of the addressing index, so a Public activity addressed to {"type": "Link", "href": [Public]} was not served. The index now reads a singleton list too. Documents indexed earlier keep their index. - Properties nested by @nest in an extension value were read as a separate node object. Such a node object is now left uninterpreted, so its document is not served. - The log of unservable revisions was cleared when full, so every one was logged again. Only the revision logged longest ago is now forgotten. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the sixth round against the code, found the same blank node expansion in document types, reproduced each gap with a test that failed before the fix, and wrote the changes, tests and documentation. Findings on a preview diagnostic for documents that are not served and on the cost of the outbox counter were left for the contributor. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 13 ++++--- docs/internals/local-writes.md | 10 ++--- .../federation/src/local-write/prepare.ts | 5 ++- packages/federation/src/serving.test.ts | 38 ++++++++++++++++++- packages/federation/src/serving/resource.ts | 9 +++-- packages/models/README.md | 7 ++-- packages/models/src/document.test.ts | 13 +++++++ packages/models/src/document.ts | 22 +++++++---- 8 files changed, 91 insertions(+), 26 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index a0de532a..19e44bec 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -55,7 +55,8 @@ is served from the document of its current revision: 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. + Each revision is logged once per process, however often it is read, + while it is among the last 4,096 logged. Conditions @@ -94,11 +95,11 @@ actor, and Public `to` or `cc` addressing in the resource's current 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, or more than - one `id` or `href`, since its recipients and references cannot then be - told. Blank node identifiers are read as names of no resource, and - language maps as strings. Such a document is logged once per revision - as a warning. + IRI, or a relative reference where a reference belongs, more than one + `id` or `href`, or properties nested by `@nest`, since its recipients + and references cannot then be told. Blank node identifiers are read as + names of no resource, and language maps as strings. Such a document is + 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. diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index 679239f9..275999b3 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -131,11 +131,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 singleton list, 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 diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index c4d1ba8c..ba9cd68b 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -402,9 +402,12 @@ 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 singleton list. 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 && entry.path.at(-1) === 0)), )?.iri; }; const occurrences = ( diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index bd5c652b..5daef8c6 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -993,6 +993,26 @@ describe("embedded documents and implicit contexts", () => { }); }); + it("reads a Public Link recipient whose href is a singleton list", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const like = await save( + env, + { + "@context": AS, + type: "Like", + to: { type: "Link", href: [PUBLIC_IRI] }, + 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)); + assert.deepEqual(await outboxIds(harness.federation), [like.iri]); + }); + }); + it("hides an activity that embeds a deleted object at any depth", async () => { await withFederation(async (harness) => { const env = await setUp(harness); @@ -1164,6 +1184,13 @@ describe("embedded documents and implicit contexts", () => { }, // A blank node is no compact IRI, whatever prefix "_" names. { type: "Note", content: "secret", to: [bob, "_:Public"] }, + // 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. @@ -1209,6 +1236,11 @@ describe("embedded documents and implicit contexts", () => { like({ type: "Delete", actor: actorIri, object: note.iri }), "activity", ); + const blankDeleting = await save( + env, + like({ type: "_:Delete", actor: actorIri, object: note.iri }), + "activity", + ); const linking = await save( env, like({ @@ -1222,13 +1254,17 @@ describe("embedded documents and implicit contexts", () => { }), "activity", ); - for (const saved of [quoting, liking, deleting, linking]) { + 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, diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index 20466fd4..df8ace1d 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -89,14 +89,17 @@ const chunks = (values: readonly T[], size: number): T[][] => ); // The revisions logged as unservable, so that one is logged once however -// often pages and counters read it. It is bounded by clearing it. +// often pages and counters read it. Past its limit, the revision logged +// longest ago is forgotten. const loggedRevisions = new Set(); -const loggedRevisionLimit = 1024; +const loggedRevisionLimit = 4096; function logOnce(row: RevisionRow, log: () => void): void { const key = row.revisionId ?? row.iri; if (loggedRevisions.has(key)) return; - if (loggedRevisions.size >= loggedRevisionLimit) loggedRevisions.clear(); + if (loggedRevisions.size >= loggedRevisionLimit) { + loggedRevisions.delete(loggedRevisions.values().next().value!); + } loggedRevisions.add(key); log(); } diff --git a/packages/models/README.md b/packages/models/README.md index 40d6fef9..ab6a9fb7 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -100,9 +100,10 @@ property holds anything but absolute IRIs, blank node identifiers and node objects: a value object, a language map, a string its key does not coerce to an IRI, a relative reference, 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. The value of a value object, or of a key with a `@language` container, is -never read for node objects, and a blank node identifier is not expanded as a -compact IRI. +one, or with properties nested by `@nest`. The value of a value object, or of a +key with a `@language` container, is never read for node objects. 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"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 1b742a17..c7acb3b4 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4426,6 +4426,19 @@ describe("describeDocument() semantic view", () => { blank?.identifiers.map(({ iri }) => iri), ["_:b0", "_:Public"], ); + assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); + assert.equal( + quotedNode({ type: "Note", "@nest": { to: "as:Public" } })?.uninterpreted, + true, + ); + 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); diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 9c2fc933..bd892562 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -583,7 +583,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) { @@ -2571,7 +2572,7 @@ export interface ExtensionNode { * than absolute IRIs, blank node identifiers and node objects, such as a * value object, a language map, a string the property's alias does not * coerce to an IRI, or a relative reference, or more than one `id` or - * `href`, or a node object as one. + * `href`, or a node object as one, or it nests properties with `@nest`. * An uninterpreted node object has no properties, identifiers or types * when its context is unread. */ @@ -2640,7 +2641,14 @@ const identifierProperties: ReadonlySet = new Set([ // 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; @@ -2779,10 +2787,7 @@ function readExtensionEntry( return true; } if (!reference) return true; - // A blank node identifier is no compact IRI, whatever prefix "_" names. - const iri = entry.startsWith("_:") - ? entry - : expandIdentifier(entry, context); + const iri = expandIdentifier(entry, context); // A literal or a relative reference names no resource the checker can // tell. if (!compatible || !isReadableReference(iri)) return false; @@ -2838,6 +2843,9 @@ function describeExtensionNode( }; let readable = true; for (const source of Object.keys(values).sort()) { + // Nested properties belong to the node object around them, which is + // not read that way. + if (keywordOf(source, context) === "@nest") readable = false; const property = extensionProperty(source, context); if (property == null) continue; const { name } = property; From 985176e8e8ced21baa2d6a24c05e448cb2355326 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 17:22:56 +0900 Subject: [PATCH 09/25] Index a Link href list whose one value follows a null Validation accepts a functional property as a list with one non-null entry at any position, but the addressing index read a Link's href list only at its first position. A Public activity addressed to {"type": "Link", "href": [null, Public]} was therefore left out of the index and not served, while serving read the same Note embedded in another document as Public. The index now reads the one value wherever it is in the list. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex, which all reported this in the seventh round. Claude Code verified it against the code, reproduced it with a test that failed before the fix, and wrote the change and test. A finding that documents indexed before these fixes keep their index was left for the contributor, since repairing it needs a data migration. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/local-writes.md | 2 +- .../federation/src/local-write/prepare.ts | 5 +-- packages/federation/src/serving.test.ts | 35 +++++++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index 275999b3..6dcfb036 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -131,7 +131,7 @@ 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`, also written as a singleton list, and anonymous values without a row. +`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 diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index ba9cd68b..cc02a5ad 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -402,12 +402,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 singleton list. + // 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.path.length === path.length + 2 && entry.path.at(-1) === 0)), + (entry.path.length === path.length + 2 && + typeof entry.path.at(-1) === "number")), )?.iri; }; const occurrences = ( diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 5daef8c6..1a97ee12 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -993,23 +993,28 @@ describe("embedded documents and implicit contexts", () => { }); }); - it("reads a Public Link recipient whose href is a singleton list", async () => { + 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 like = await save( - env, - { - "@context": AS, - type: "Like", - to: { type: "Link", href: [PUBLIC_IRI] }, - 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)); - assert.deepEqual(await outboxIds(harness.federation), [like.iri]); + 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); }); }); From 488c85ff09741449cd0ea1bb22d35dc522726bea Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 17:46:05 +0900 Subject: [PATCH 10/25] Check identity claims and malformed values in extension node objects The eighth review round found two gaps opened by serving the node objects of extension values: - Identity claims were checked in described documents only, so an extension value could embed an object with an unregistered id on an instance host, or attributed to an actor of another instance, and federation reads served it from the instance's own host. The write path now checks the node objects of extension values in the same way. - A value or list object with other properties, such as {"@set": [], "bcc": ...}, is no valid JSON-LD, but was not read as a node object, so its bcc was served. Such an object is now reported as an uninterpreted node object, so its document is not served. So is one with a context of its own whose keys may alias keywords, since they cannot be told apart from properties. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the eighth round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests and documentation. A finding that the once-per-revision log is untested was left, since the LogTape test helper captures no records. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 9 +-- docs/internals/local-writes.md | 19 +++--- packages/federation/src/local-write.test.ts | 59 +++++++++++++++++++ .../federation/src/local-write/prepare.ts | 14 +++-- packages/federation/src/serving.test.ts | 21 ++++--- packages/models/README.md | 5 +- packages/models/src/document.test.ts | 21 +++++-- packages/models/src/document.ts | 18 +++++- 8 files changed, 133 insertions(+), 33 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 19e44bec..b88f7b61 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -96,10 +96,11 @@ actor, and Public `to` or `cc` addressing in the resource's current 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`, or properties nested by `@nest`, since its recipients - and references cannot then be told. Blank node identifiers are read as - names of no resource, and language maps as strings. Such a document is - logged once per revision as a warning. + `id` or `href`, properties nested by `@nest`, or a value or a list + object with keys other than keywords, or with keys a context of its own + may alias, since its recipients and references cannot then be told. Blank + node identifiers are read as names of no resource, and language maps as + strings. Such a document is 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. diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index 6dcfb036..145b5c95 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -109,15 +109,16 @@ 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 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 diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index dce8be5b..229f3488 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -653,6 +653,65 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { }); }); + it("checks identity claims in the node objects of extension values", async () => { + await withFederation(async (harness) => { + 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"], + ], + ] 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 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) => { await seedMember(harness.db); diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index cc02a5ad..7a6c73a0 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -300,10 +300,11 @@ function resourceIri( /** * 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. + * new resources, and in the node objects of their extension values, which + * federation reads serve too: `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. */ export async function deniedClaims( @@ -315,7 +316,10 @@ export async function deniedClaims( ): Promise { const actors: { iri: string; path: FieldPath }[] = []; const ids: { iri: string; path: FieldPath }[] = []; - for (const node of nodes) { + for (const node of [ + ...nodes, + ...nodes.flatMap(({ extensions }) => extensions), + ]) { // 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 1a97ee12..6d8c937e 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1138,8 +1138,7 @@ describe("embedded documents and implicit contexts", () => { 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 JSON literal read with an alias of - // @type from a context of its own stay readable. + // 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] }, @@ -1149,11 +1148,6 @@ describe("embedded documents and implicit contexts", () => { to: [PUBLIC_IRI], labels: { id: "Halo", bcc: "Hello" }, }, - { - "@context": { kind: "@type" }, - kind: "@json", - "@value": { bcc: "data" }, - }, ]) { const saved = await save(env, like(quoted), "activity"); const response = await get(harness.federation, saved.iri); @@ -1189,6 +1183,19 @@ describe("embedded documents and implicit contexts", () => { }, // A blank node is no compact IRI, whatever prefix "_" names. { type: "Note", content: "secret", to: [bob, "_:Public"] }, + // 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", diff --git a/packages/models/README.md b/packages/models/README.md index ab6a9fb7..5ff92cd1 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -100,7 +100,10 @@ property holds anything but absolute IRIs, blank node identifiers and node objects: a value object, a language map, a string its key does not coerce to an IRI, a relative reference, 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`. The value of a value object, or of a +one, or with properties nested by `@nest`. A value or a list object with keys +other than keywords, which is no valid JSON-LD, or 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, is never read for node objects. A blank node identifier is never expanded as a compact IRI, whatever term `_` names, so a type spelled as one is unsupported. diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index c7acb3b4..9c7db658 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4427,6 +4427,11 @@ describe("describeDocument() semantic view", () => { ["_:b0", "_:Public"], ); assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); + // A value or a list with properties is no valid JSON-LD, and unread. + assert.equal( + quotedNode({ "@set": [], bcc: "https://a.example/b" })?.uninterpreted, + true, + ); assert.equal( quotedNode({ type: "Note", "@nest": { to: "as:Public" } })?.uninterpreted, true, @@ -4458,14 +4463,20 @@ describe("describeDocument() semantic view", () => { })?.[0]?.extensions.length, 0, ); - // The value of a value object holds no node objects, whatever its type. - assert.equal( - quotedNode({ + // 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" }, - }), - undefined, + }, + })?.[0]?.extensions; + assert.deepEqual( + aliased?.map(({ path, uninterpreted }) => ({ path, uninterpreted })), + [{ path: ["ex:quoted"], uninterpreted: true }], ); // A JSON literal holds data, however its keywords are spelled. assert.ok( diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index bd892562..b4efb57a 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1609,6 +1609,19 @@ function isNodeObject( ); } +// 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, which is +// 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) => keywordOf(key, context) == null) + ? null + : undefined; +} + // Whether a value object holds a JSON literal, however its keywords are // spelled. function isJsonLiteral( @@ -1792,8 +1805,9 @@ function checkDocumentValue( const own = extensionContext(values, context); const jsonLiteral = keywords && checkJsonLiteral(values, path, report, own); - if (keywords && own !== undefined && isNodeObject(values, own)) { - onExtensionNode?.(values, path, 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"); From 066a2c4acd38d4f9076bbaa283eede272db11f83 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 18:06:38 +0900 Subject: [PATCH 11/25] Read only a Link's href as a recipient, and check it as an actor The ninth review round found these gaps: - A node object in an extension value that was no Link was read as a recipient by its href when it had no id, so a Note addressed to bob with a cc of {"type": "Note", "href": Public} was served. Only a Link now names a recipient by its href. - The href of a Link under actor or attributedTo was not checked as an identity claim, in a described document or in an extension value, so a Note could be attributed to an actor of another instance through {"type": "Link", "href": ...}. It is now checked as the id of a document embedded there is. - Properties reversed by @reverse in an extension value make other node objects their subjects, and were read as the node object's own. A value object whose value is an object but no JSON literal is no valid JSON-LD, and its payload was served unread. Both now leave the node object uninterpreted, so their documents are not served. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. Claude Code verified each finding of the ninth round against the code, reproduced each with a test that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 11 ++++---- docs/internals/local-writes.md | 3 +- packages/federation/src/local-write.test.ts | 10 +++++++ .../federation/src/local-write/prepare.ts | 8 ++++-- packages/federation/src/serving.test.ts | 14 ++++++++++ packages/federation/src/serving/document.ts | 8 +++--- packages/models/README.md | 5 ++-- packages/models/src/document.test.ts | 18 ++++++++---- packages/models/src/document.ts | 28 ++++++++++++++----- 9 files changed, 78 insertions(+), 27 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index b88f7b61..4bc426b3 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -88,17 +88,18 @@ actor, and Public `to` or `cc` addressing in the resource's current 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` or else - `href` otherwise. A node object whose `object` refers to a deleted + 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 a value or a list - object with keys other than keywords, or with keys a context of its own - may alias, since its recipients and references cannot then be told. Blank + `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. Blank node identifiers are read as names of no resource, and language maps as strings. Such a document is logged once per revision as a warning. - An object must be attributed to the actor of its route by its last diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index 145b5c95..bcef75a3 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -111,7 +111,8 @@ Every other identity claim is checked by meaning, through `describeDocument()`, so aliases, full-IRI keys, `@id` aliases and compact IRIs 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 must be an actor of the acting +`attributedTo` value on a DrFed instance host, including the `id` of a document +or the `href` of a `Link` embedded there, 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 diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index 229f3488..f4fe1210 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -684,6 +684,16 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { }, ["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, diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index 7a6c73a0..7d4ad716 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -301,8 +301,9 @@ function resourceIri( /** * Finds identity claims on DrFed instance hosts in documents that are not * new resources, and in the node objects of their extension values, which - * federation reads serve too: `actor` and `attributedTo` values, which must - * be actors of the acting actor's instance, and `id` values, which must be + * federation reads serve too: `actor` and `attributedTo` values, including + * the `id` or `href` of a document embedded there, 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. @@ -331,10 +332,11 @@ 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) || diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 6d8c937e..5d30cc9f 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1183,6 +1183,20 @@ describe("embedded documents and implicit contexts", () => { }, // 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. { diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index cbfb1bb7..cb0b761e 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -129,15 +129,15 @@ const embeddedDocument = (node: DocumentNode): Embedded => { }; }; -// A node object in an extension value is read as a Link when one of its -// types is, and as a recipient by its id or else its href otherwise. Its -// href is a reference whatever its type, and its id is embedded. +// 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), + iri: node.kind === "link" ? href : ownId(node), href, }; }; diff --git a/packages/models/README.md b/packages/models/README.md index 5ff92cd1..51dc77cd 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -100,8 +100,9 @@ property holds anything but absolute IRIs, blank node identifiers and node objects: a value object, a language map, a string its key does not coerce to an IRI, a relative reference, 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`. A value or a list object with keys -other than keywords, which is no valid JSON-LD, or with keys a context of its +one, or with properties nested by `@nest` or reversed by `@reverse`. 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, is never read for node objects. A blank node diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 9c7db658..b07d362c 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4427,11 +4427,19 @@ describe("describeDocument() semantic view", () => { ["_:b0", "_:Public"], ); assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); - // A value or a list with properties is no valid JSON-LD, and unread. - assert.equal( - quotedNode({ "@set": [], bcc: "https://a.example/b" })?.uninterpreted, - true, - ); + // A value or a list with properties is no valid JSON-LD, and unread, + // as is a value that is an object but no JSON literal. + for (const quoted of [ + { "@set": [], bcc: "https://a.example/b" }, + { "@value": { bcc: "https://a.example/b" } }, + { type: "Note", "@reverse": { attributedTo: { type: "Note" } } }, + ]) { + assert.equal( + quotedNode(quoted)?.uninterpreted, + true, + JSON.stringify(quoted), + ); + } assert.equal( quotedNode({ type: "Note", "@nest": { to: "as:Public" } })?.uninterpreted, true, diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index b4efb57a..8d754a0b 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1610,14 +1610,26 @@ function isNodeObject( } // 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, which is -// no valid JSON-LD and cannot be read. +// 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) => keywordOf(key, context) == null) + 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; } @@ -2586,7 +2598,8 @@ export interface ExtensionNode { * than absolute IRIs, blank node identifiers and node objects, such as a * value object, a language map, a string the property's alias does not * coerce to an IRI, or a relative reference, or more than one `id` or - * `href`, or a node object as one, or it nests properties with `@nest`. + * `href`, or a node object as one, or it nests properties with `@nest` or + * reverses them with `@reverse`. * An uninterpreted node object has no properties, identifiers or types * when its context is unread. */ @@ -2857,9 +2870,10 @@ function describeExtensionNode( }; let readable = true; for (const source of Object.keys(values).sort()) { - // Nested properties belong to the node object around them, which is - // not read that way. - if (keywordOf(source, context) === "@nest") readable = false; + // 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; From 675571a077422dcc11035ea096508678bf9606ee Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 18:31:30 +0900 Subject: [PATCH 12/25] Leave blank node references in extension node objects unread A blank node identifier joins the node objects of a document that use it, so a reference to one may name a node object defined apart. An extension value could therefore attribute a Note to "_:author" and define "_:author" elsewhere as a Link to an actor of another instance, which neither node object's claims showed. A blank node identifier is now read only as the id of the node object that has it; as any other reference, it leaves the node object uninterpreted, so its document is not served. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. In the tenth round Fable and Sol found no issues and Astra reported this one. Claude Code verified it against the code, reproduced it with a test that failed before the fix, and wrote the change, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra --- docs/internals/federation-reads.md | 8 +- packages/federation/src/serving.test.ts | 11 +- packages/models/README.md | 24 ++--- packages/models/src/document.test.ts | 137 +++++++++++------------- packages/models/src/document.ts | 18 ++-- 5 files changed, 102 insertions(+), 96 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 4bc426b3..4b5c98f6 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -99,9 +99,11 @@ actor, and Public `to` or `cc` addressing in the resource's current `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. Blank - node identifiers are read as names of no resource, and language maps as - strings. Such a document is logged once per revision as a warning. + 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. + Language maps are read as strings. Such a document is 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. diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 5d30cc9f..0c4e2baa 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1181,8 +1181,17 @@ describe("embedded documents and implicit contexts", () => { "@set": ["as:Public"], }, }, - // A blank node is no compact IRI, whatever prefix "_" names. + // A blank node is no compact IRI, whatever prefix "_" names, and as + // a reference it may name a node object defined apart. { type: "Note", content: "secret", to: [bob, "_:Public"] }, + [ + { type: "Note", content: "forged", attributedTo: "_:author" }, + { + id: "_:author", + type: "Link", + href: "https://other.drfed.org/users/victim", + }, + ], // Only a Link names a recipient by its href. { type: "Note", diff --git a/packages/models/README.md b/packages/models/README.md index 51dc77cd..4f3ba8da 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -96,18 +96,18 @@ IRI references as a node has, and the `parent` node object, `types` and the 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, blank node identifiers and node -objects: a value object, a language map, a string its key does not coerce to -an IRI, a relative reference, 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`. 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, is never read for node objects. A blank node -identifier is never expanded as a compact IRI, whatever term `_` names, so a -type spelled as one is unsupported. +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`. +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, is never +read for node objects. 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"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index b07d362c..d52e5f63 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4376,22 +4376,23 @@ describe("describeDocument() semantic view", () => { 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", () => { - 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]; for (const quoted of [ { type: "Note", hint: "as:Public" }, { type: "Note", [`${context}#to`]: "as:Public" }, @@ -4403,6 +4404,15 @@ describe("describeDocument() semantic view", () => { { 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, @@ -4410,6 +4420,24 @@ describe("describeDocument() semantic view", () => { JSON.stringify(quoted), ); } + // 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", @@ -4418,32 +4446,15 @@ describe("describeDocument() semantic view", () => { assert.equal(link?.uninterpreted, false); assert.equal(link?.kind, "link"); assert.equal(quotedNode({ type: "Note", content: "x" })?.kind, "object"); - // A blank node identifier names no resource, and stays readable, but - // is no compact IRI, whatever prefix "_" names. - const blank = quotedNode({ type: "Note", id: "_:b0", to: "_:Public" }); + // 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", "_:Public"], + ["_:b0", `${context}#Public`], ); assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); - // A value or a list with properties is no valid JSON-LD, and unread, - // as is a value that is an object but no JSON literal. - for (const quoted of [ - { "@set": [], bcc: "https://a.example/b" }, - { "@value": { bcc: "https://a.example/b" } }, - { type: "Note", "@reverse": { attributedTo: { type: "Note" } } }, - ]) { - assert.equal( - quotedNode(quoted)?.uninterpreted, - true, - JSON.stringify(quoted), - ); - } - assert.equal( - quotedNode({ type: "Note", "@nest": { to: "as:Public" } })?.uninterpreted, - true, - ); assert.equal( describeDocument({ ...like, @@ -4455,44 +4466,26 @@ describe("describeDocument() semantic view", () => { // A language map holds strings, whatever its language tags. const labelled = quotedNode({ type: "Note", labels: { id: "Halo" } }); assert.equal(labelled?.uninterpreted, false); - assert.equal( - describeDocument({ - ...like, - "@context": [ - context, - { - labels: { - "@id": "https://example.com/labels", - "@container": "@language", - }, - }, - ], - labels: { id: "Halo", bcc: "x" }, - })?.[0]?.extensions.length, - 0, - ); - // A value whose keys a context of its own may alias cannot be read, but - // its value holds no node objects. - const aliased = describeDocument({ + const labels = 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 }], - ); + "@context": [ + context, + { + labels: { + "@id": "https://example.com/labels", + "@container": "@language", + }, + }, + ], + labels: { id: "Halo", bcc: "x" }, + }); + assert.deepEqual(labels?.[0]?.extensions, []); // A JSON literal holds data, however its keywords are spelled. - assert.ok( - describeDocument({ - ...like, - custom: { type: "@json", "@value": { "@id": 1, bcc: "x" } }, - })?.[0]?.extensions.length === 0, - ); + 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", () => { diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index 8d754a0b..d993b26b 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -2595,13 +2595,13 @@ export interface ExtensionNode { * 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, blank node identifiers and node objects, such as a - * value object, a language map, a string the property's alias does not - * coerce to an IRI, or a relative reference, or more than one `id` or - * `href`, or a node object as one, or it nests properties with `@nest` or - * reverses them with `@reverse`. - * An uninterpreted node object has no properties, identifiers or types - * when its context is unread. + * 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`. An uninterpreted node object has no properties, identifiers + * or types when its context is unread. */ readonly uninterpreted: boolean; /** @@ -2816,8 +2816,10 @@ function readExtensionEntry( if (!reference) return true; const iri = expandIdentifier(entry, context); // A literal or a relative reference names no resource the checker can - // tell. + // 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; + if (iri.startsWith("_:") && name !== "id") return false; reading.identifiers.push({ property: name, path, value: entry, iri }); return true; } From 3d06d4c70273077836346a6a129b526f258330c8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 18:51:54 +0900 Subject: [PATCH 13/25] Leave extension node objects whose ids are used elsewhere unread Node objects that share an id are one JSON-LD node, and a node object whose id another value refers to says more of what that value names. An extension value could therefore attribute a Note to {"id": "_:author"} and define "_:author" apart as a Link to an actor of another instance, or define an id the document refers to as a Link to a deleted object, while each node object was read on its own. A node object in an extension value whose id is used elsewhere in the document, as another node object's id or as a reference, is now uninterpreted, so its document is not served. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. In the eleventh round Sol found no issues, and Fable and Astra reported this one. Claude Code verified it against the code, reproduced it with a test that failed before the fix, and wrote the change, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra --- docs/internals/federation-reads.md | 5 ++- packages/federation/src/serving.test.ts | 12 +++++- packages/models/README.md | 16 ++++---- packages/models/src/document.test.ts | 21 ++++++++++ packages/models/src/document.ts | 53 ++++++++++++++++--------- 5 files changed, 80 insertions(+), 27 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 4b5c98f6..5c22cbbc 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -101,7 +101,10 @@ actor, and Public `to` or `cc` addressing in the resource's current 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. + 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, is one node with what uses it, + and is not read apart either. Language maps are read as strings. Such a document is logged once per revision as a warning. - An object must be attributed to the actor of its route by its last diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 0c4e2baa..2e06bc31 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1181,6 +1181,16 @@ describe("embedded documents and implicit contexts", () => { "@set": ["as:Public"], }, }, + // A node object whose id is used elsewhere is not read apart. + [ + { type: "Note", content: "forged", attributedTo: { id: "_:author" } }, + { + id: "_:author", + type: "Link", + href: "https://other.drfed.org/users/victim", + }, + ], + { id: "https://remote.example/notes/1", type: "Note", to: [bob] }, // A blank node is no compact IRI, whatever prefix "_" names, and as // a reference it may name a node object defined apart. { type: "Note", content: "secret", to: [bob, "_:Public"] }, @@ -1254,7 +1264,7 @@ describe("embedded documents and implicit contexts", () => { env, like([ ...Array.from({ length: 1000 }, (_, index) => ({ - id: `https://remote.example/notes/${index}`, + id: `https://remote.example/quoted/${index}`, })), { id: note.iri, type: "Note", content: "x" }, ]), diff --git a/packages/models/README.md b/packages/models/README.md index 4f3ba8da..2f794868 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -101,13 +101,15 @@ property holds anything but absolute IRIs, a blank node identifier as its 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`. -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, is never -read for node objects. A blank node identifier is never expanded as a compact -IRI, whatever term `_` names, so a type spelled as one is unsupported. +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, since it is then not read apart. 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, is never read for node +objects. 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"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index d52e5f63..ee402c14 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4420,6 +4420,27 @@ describe("describeDocument() semantic view", () => { 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" }, + ], + })?.[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 }, + ], + ); // A value whose keys a context of its own may alias cannot be read, but // its value holds no node objects. const aliased = describeDocument({ diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index d993b26b..fb8524f3 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -2600,8 +2600,10 @@ export interface ExtensionNode { * 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`. An uninterpreted node object has no properties, identifiers - * or types when its context is unread. + * `@reverse`, or its `id` is used elsewhere in the document, as the `id` of + * another node object or a reference, so that it is not read apart. An + * uninterpreted node object has no properties, identifiers or types when + * its context is unread. */ readonly uninterpreted: boolean; /** @@ -2988,6 +2990,17 @@ export function describeDocument( } // Read values only now: validation has rejected sparse arrays, accessors, // and other non-JSON shapes without evaluating them. + 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), ); @@ -2999,12 +3012,24 @@ export function describeDocument( ]), ), ); - const extensionNodes = described.map( - ({ node }): ExtensionNode => ({ + // 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. + const uses = Map.groupBy( + [...documents, ...described.map(({ node }) => node)].flatMap( + ({ identifiers }) => identifiers, + ), + ({ iri }) => iri, + ); + const extensionNodes = described.map(({ node }): ExtensionNode => { + const id = node.identifiers.find(({ property }) => property === "id"); + const joined = id != null && (uses.get(id.iri)?.length ?? 0) > 1; + return { ...node, ...holders.get(JSON.stringify(node.path)), - }), - ); + ...(joined ? { uninterpreted: true } : {}), + }; + }); const owners = new Map( visited.map(({ path }): [string, ExtensionNode[]] => [ JSON.stringify(path), @@ -3021,18 +3046,10 @@ export function describeDocument( } } return nameEmbeddingProperties( - visited.map( - ({ values, path, context, Type, fields }): DocumentNode => ({ - path, - type: Type.typeId.href, - kind: nodeKind(Type), - ...describeProperties( - values, - path, - fields.filter(({ compatible }) => compatible), - context, - ), - extensions: owners.get(JSON.stringify(path)) ?? [], + documents.map( + (node): DocumentNode => ({ + ...node, + extensions: owners.get(JSON.stringify(node.path)) ?? [], }), ), ); From b4536e8f1add937101aa44c18b04836f02be4dea Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 19:14:40 +0900 Subject: [PATCH 14/25] Read documents that share an id, and objects in language maps The twelfth review round found these gaps: - Embedded documents that share an id are one JSON-LD node, so an Add whose object was {"type": "Link", "id": L} and whose target was {"type": "Link", "id": L, "href": N} referred to N through its object, which the deletion rule did not see. A document in which two node objects share an id is now not served. An object reference now also names the href of a Link that the document embeds elsewhere with that id. - An object in a language map is no valid JSON-LD, and its keys, including bcc, were served as saved. It is now reported as an uninterpreted node object, so its document is not served. - Tests now show that a legacy Create answers 404 at the general activity route. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. In the twelfth round Sol found no issues, and Fable and Astra reported these. Claude Code verified each against the code, reproduced the gaps with tests that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra --- docs/internals/federation-reads.md | 8 ++- packages/federation/src/serving.test.ts | 58 +++++++++++++++++++++ packages/federation/src/serving/document.ts | 34 +++++++++--- packages/federation/src/serving/resource.ts | 8 +-- packages/models/README.md | 5 +- packages/models/src/document.test.ts | 24 +++++++++ packages/models/src/document.ts | 38 ++++++++++++-- 7 files changed, 158 insertions(+), 17 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 5c22cbbc..045661e3 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -104,8 +104,12 @@ actor, and Public `to` or `cc` addressing in the resource's current 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, is one node with what uses it, - and is not read apart either. - Language maps are read as strings. Such a document is logged once per + and is not read apart either. Language maps are read as strings, and + an object in one, which is no valid JSON-LD, is not 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` diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 2e06bc31..8161c192 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -790,6 +790,16 @@ describe("federation reads of saved objects and their history", () => { assert.equal(object.body.content, "

B

"); const legacyCreate = await get(harness.federation, createIri); assert.equal(legacyCreate.status, 200); + // 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); @@ -1018,6 +1028,47 @@ describe("embedded documents and implicit contexts", () => { }); }); + it("reads documents that share an id as JSON-LD joins them", 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 link = "https://remote.example/links/1"; + // 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("hides an activity that embeds a deleted object at any depth", async () => { await withFederation(async (harness) => { const env = await setUp(harness); @@ -1181,6 +1232,13 @@ describe("embedded documents and implicit contexts", () => { "@set": ["as:Public"], }, }, + // 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. [ { type: "Note", content: "forged", attributedTo: { id: "_:author" } }, diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index cb0b761e..318e780e 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -187,17 +187,20 @@ const isRestricted = ( /** * Reads the IRIs an activity's `object` refers to without embedding them, as - * references or by the `href` of `Link` values. + * 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") - .map(({ iri }) => iri), + .flatMap(({ iri }) => [iri, ...(links.get(iri) ?? [])]), ...children .filter((child) => child.property === "object") .map(({ href }) => href) @@ -239,8 +242,9 @@ export interface DocumentInspection { readonly restricted: boolean; /** * Whether the validator cannot read all of an extension node object, as - * when it has a `@context` of its own, so that its blind recipients cannot - * be found. + * 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; } @@ -267,6 +271,22 @@ export function inspectDocument( ); 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, @@ -288,11 +308,13 @@ export function inspectDocument( ({ kind, type }) => kind === "activity" && type !== deleteType, ), ...extensions.filter(({ types }) => !types.includes(deleteType)), - ].flatMap((node) => referredObjects(node, children(node))), + ].flatMap((node) => referredObjects(node, children(node), links)), restricted: [...embedded, ...extensions].some((node) => isRestricted(node, children(node)), ), - uninterpreted: extensions.some(({ uninterpreted }) => uninterpreted), + uninterpreted: + extensions.some(({ uninterpreted }) => uninterpreted) || + new Set(ids).size < ids.length, }; } diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index df8ace1d..e2a621dd 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -113,8 +113,8 @@ function isJsonObject(value: unknown): value is JsonObject { * 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 and * left out, so that it takes down nothing but its own resource. A document - * whose extension values cannot be read is logged as a warning, and is not - * served either. Each revision is logged once. + * whose node objects cannot be read apart, as in extension values, is logged + * as a warning, and is not served either. Each revision is logged once. * @returns The inspection, or `null` when the document cannot be served. */ function inspectRevision(row: RevisionRow): DocumentInspection | null { @@ -125,8 +125,8 @@ function inspectRevision(row: RevisionRow): DocumentInspection | null { if (inspection.uninterpreted) { logOnce(row, () => logger.warn( - "The saved document of {iri} has an extension value whose " + - "addressing cannot be read; it is not served.", + "The saved document of {iri} has node objects whose addressing " + + "or references cannot be read; it is not served.", { iri: row.iri }, ), ); diff --git a/packages/models/README.md b/packages/models/README.md index 2f794868..0e0ad96a 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -108,8 +108,9 @@ 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, is never read for node -objects. A blank node identifier is never expanded as a compact IRI, whatever -term `_` names, so a type spelled as one is unsupported. +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"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index ee402c14..7503ecc1 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4501,6 +4501,30 @@ describe("describeDocument() semantic view", () => { labels: { id: "Halo", bcc: "x" }, }); assert.deepEqual(labels?.[0]?.extensions, []); + // 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, diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index fb8524f3..e9610051 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -2915,6 +2915,29 @@ function describeExtensionNode( }; } +// 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 (map === null || typeof map !== "object") return []; + return Object.entries(map).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< @@ -3001,9 +3024,18 @@ export function describeDocument( context, ), })); - const described = extensions.map(({ values, path, context }) => - describeExtensionNode(values, path, 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]) => [ From dc27c90b57095cef54c63f991df03af14e3cd519 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 6 Nov 2026 19:43:59 +0900 Subject: [PATCH 15/25] Check a Link's href where an attribution names its id The thirteenth review round found these gaps: - An attributedTo or actor value naming the id of a Link embedded elsewhere in the document is attributed to the Link's href, but only a Link embedded in place was checked as an identity claim. So a Note attributed to a remote IRI that a target Link defined with the href of another instance's actor was saved and served. The href of a Link embedded elsewhere with the id such a value names is now checked as an identity claim, in described documents and extension values. A blank node reference in an extension node object is listed for this check, while still leaving the node object uninterpreted. - The value of a key with a @language container is a language map only when it is an object; a list there expands as ordinary values, but its node objects were not read, and the document was refused only by accident. They are now read as other node objects are. - Tests now request a saved object and its Tombstone at the route of another live actor of the same instance, which the attribution predicate refuses. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the branch with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex, and to stop after this round. In the thirteenth round Sol found no issues, and Fable and Astra reported these. Claude Code verified each against the code, reproduced the gaps with tests that failed before the fix, and wrote the changes, tests and documentation. mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra --- docs/internals/federation-reads.md | 4 +- docs/internals/local-writes.md | 5 +- packages/federation/src/local-write.test.ts | 61 ++++++++++++++++++ .../federation/src/local-write/prepare.ts | 40 ++++++++---- packages/federation/src/serving.test.ts | 64 +++++++++++++------ packages/models/README.md | 9 +-- packages/models/src/document.test.ts | 27 ++++++++ packages/models/src/document.ts | 44 ++++++++----- 8 files changed, 202 insertions(+), 52 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 045661e3..40662ede 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -105,7 +105,9 @@ actor, and Public `to` or `cc` addressing in the resource's current node object whose `id` is used elsewhere in the document, as another node object's `id` or as a reference, is one node with what uses it, and is not read apart either. Language maps are read as strings, and - an object in one, which is no valid JSON-LD, is not read. + 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 diff --git a/docs/internals/local-writes.md b/docs/internals/local-writes.md index bcef75a3..476905e8 100644 --- a/docs/internals/local-writes.md +++ b/docs/internals/local-writes.md @@ -112,8 +112,9 @@ Every other identity claim is checked by meaning, through 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, must be an actor of the acting -actor's instance, and an `id` there must be a registered resource of a known +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 diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index f4fe1210..cc8a6550 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -710,6 +710,67 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { [["unauthorized_local_identity", path]], ); } + // A reference names what a Link embedded elsewhere with its id does. + const link = "https://remote.example/links/1"; + const victim = "https://other.drfed.org/users/victim"; + 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 remote identity is not checked, as in any embedded document. await save( env, diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index 7d4ad716..ea13b50b 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -298,13 +298,17 @@ function resourceIri( ).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, 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, 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 + * 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. */ @@ -317,10 +321,22 @@ export async function deniedClaims( ): Promise { const actors: { iri: string; path: FieldPath }[] = []; const ids: { iri: string; path: FieldPath }[] = []; - for (const node of [ + 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. @@ -338,13 +354,13 @@ export async function deniedClaims( property === "attributedTo" || ((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([ diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 8161c192..58ce753d 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -58,6 +58,7 @@ import { expires, localActorId, localInstanceId, + seedActors, seedLocalActor, } from "./seed.test.ts"; @@ -626,6 +627,43 @@ describe("federation reads of saved objects and their history", () => { }); }); + it("serves a saved object and its Tombstone at its author's route only", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const otherId = "00000000-0000-4000-8000-000000000203" as Uuid; + await harness.db + .insert(schema.localActors) + .values({ id: otherId, avatar: "avatar.png", header: "header.png" }); + await seedActors( + harness.db, + { + id: otherId, + localId: otherId, + instanceId: localInstanceId, + type: "Person", + username: "bob", + iri: `https://${host}/users/${otherId}`, + inboxUrl: `https://${host}/users/${otherId}/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, otherId); + assert.equal((await get(harness.federation, elsewhere)).status, 404); + } + }); + }); + for (const scenario of [ "wrongHost", "otherInstance", @@ -1232,6 +1270,13 @@ describe("embedded documents and implicit contexts", () => { "@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", @@ -1240,26 +1285,9 @@ describe("embedded documents and implicit contexts", () => { labels: { en: { bcc: [blind] } }, }, // A node object whose id is used elsewhere is not read apart. - [ - { type: "Note", content: "forged", attributedTo: { id: "_:author" } }, - { - id: "_:author", - type: "Link", - href: "https://other.drfed.org/users/victim", - }, - ], { id: "https://remote.example/notes/1", type: "Note", to: [bob] }, - // A blank node is no compact IRI, whatever prefix "_" names, and as - // a reference it may name a node object defined apart. + // A blank node is no compact IRI, whatever prefix "_" names. { type: "Note", content: "secret", to: [bob, "_:Public"] }, - [ - { type: "Note", content: "forged", attributedTo: "_:author" }, - { - id: "_:author", - type: "Link", - href: "https://other.drfed.org/users/victim", - }, - ], // Only a Link names a recipient by its href. { type: "Note", diff --git a/packages/models/README.md b/packages/models/README.md index 0e0ad96a..4cc28318 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -107,10 +107,11 @@ node object or a reference, since it is then not read apart. 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, 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. +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"; diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 7503ecc1..39b54dc7 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4501,6 +4501,33 @@ describe("describeDocument() semantic view", () => { 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, diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index e9610051..a3a24012 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -1656,17 +1656,23 @@ function isJsonLiteral( // 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" + (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( @@ -1828,7 +1834,7 @@ function checkDocumentValue( values[key], [...path, key], childKeywords, - extensionChildContext(key, own), + extensionChildContext(key, values[key], own), ), ); } @@ -1955,7 +1961,7 @@ function checkDocumentValue( value, fieldPath, true, - extensionChildContext(source, context), + extensionChildContext(source, value, context), ); return; } @@ -2821,9 +2827,9 @@ function readExtensionEntry( // 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; - if (iri.startsWith("_:") && name !== "id") return false; + // It is still listed, so that identity checks follow it. reading.identifiers.push({ property: name, path, value: entry, iri }); - return true; + return !iri.startsWith("_:") || name === "id"; } // Null stands for no value. if (entry === null) return true; @@ -2883,7 +2889,10 @@ function describeExtensionNode( 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") { + if ( + context.terms.get(source)?.container === "@language" && + isLanguageMap(values[source]) + ) { if (identifierProperties.has(name) && values[source] != null) { readable = false; } @@ -2926,15 +2935,20 @@ function languageMapObjects( return Object.keys(values).flatMap((source) => { if (context.terms.get(source)?.container !== "@language") return []; const map = values[source]; - if (map === null || typeof map !== "object") return []; - return Object.entries(map).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); - }); + 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); + }, + ); }); } From 744f6ca924a2f132ff16d05c69faef659906b1d8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Nov 2026 13:56:18 +0900 Subject: [PATCH 16/25] Serve every activity type as saved, on both backends Federation reads were tested with eight representative documents only. A new test saves a document of every activity type that saved activities cover, Activity, IntransitiveActivity and the 28 types ActivityStreams names, each Public with a blind recipient and an extension value, and requests each one at its route: a Create at /ap/creates/, the others at /ap/activities/, and 404 at the other one. Each is served as publicDocument() makes it, keeps its extension value, and parses with parseVocab() as its own type into an object that serializes back to the same document. The outbox counts all 30 and pages them newest first across its two pages. The same loop runs, with fewer checks, on PostgreSQL, where the visibility SQL and the JSON columns could behave apart from PGlite. Both tests take the types and the document from one fixture module. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to carry out a local plan for the completion criterion still open, fixtures of every activity type through serialization, which only the storage round trip in @drfed/models covered. Claude Code wrote the tests and the fixture module. mise run build, mise run check and mise run test pass, and mise run test:postgres passes against a local PostgreSQL 18 container. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/local-write-postgres.test.ts | 76 ++++++++++++---- .../federation/src/serving-fixture.test.ts | 91 +++++++++++++++++++ packages/federation/src/serving.test.ts | 58 +++++++++++- 3 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 packages/federation/src/serving-fixture.test.ts diff --git a/packages/federation/src/local-write-postgres.test.ts b/packages/federation/src/local-write-postgres.test.ts index 463e61fc..56dfb953 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -49,6 +49,7 @@ import { seedActors, seedLocalActor, } from "./seed.test.ts"; +import { activityTypes, typeDocument } from "./serving-fixture.test.ts"; // This service is an isolated, author-owned test cluster. // oxlint-disable-next-line node/no-process-env @@ -328,29 +329,37 @@ async function write( 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 actorIri = `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 actorIri = `https://test-instance.drfed.org/users/${localActorId}`; - const get = async (iri: string) => { - 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) - : {}, - }; - }; + const get = (iri: string) => fetchDocument(env, iri); const [like] = await write(env, { operation: "create", actorId: localActorId, @@ -415,4 +424,39 @@ describe("federation reads on PostgreSQL", () => { }); }, ); + + 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 activityTypes) { + const [saved] = await write(env, { + operation: "create", + actorId: localActorId, + document: typeDocument(type), + resources: [{ path: [], kind: "activity" }], + publish: true, + }); + const { status, body } = await get(saved!.iri); + assert.equal(status, 200, type); + assert.deepEqual(body, publicDocument(saved!.document), type); + iris.push(saved!.iri); + } + const ids = (body: Record) => + (body.orderedItems as JsonObject[]).map(({ id }) => id); + const collection = await get(`${actorIri}/outbox`); + assert.equal(collection.body.totalItems, activityTypes.length); + const first = await get(`${actorIri}/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/serving-fixture.test.ts b/packages/federation/src/serving-fixture.test.ts new file mode 100644 index 00000000..6372f65d --- /dev/null +++ b/packages/federation/src/serving-fixture.test.ts @@ -0,0 +1,91 @@ +// 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 { PUBLIC_IRI } from "@drfed/models/resource"; + +/** + * `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. + */ +export const activityTypes = [ + "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 activityTypes)[number]; + +/** The blind recipient every fixture is addressed to. */ +const blindRecipient = "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 extensionValue: JsonObject = { + ordered: [2, 1, 2], + enabled: false, + empty: null, +}; + +/** + * 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": [ + "https://www.w3.org/ns/activitystreams", + { test: "https://example.test/vocab#" }, + ], + type, + to: [PUBLIC_IRI], + bto: [blindRecipient], + "test:extension": structuredClone(extensionValue), +}); diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 58ce753d..10d76d56 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -48,7 +48,7 @@ import { } from "@drfed/models/resource-revision"; import { type Uuid, uuidV7 } from "@drfed/models/uuid"; import type { Federation } from "@fedify/fedify"; -import { Like } from "@fedify/vocab"; +import { Like, getTypeId } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; @@ -61,6 +61,11 @@ import { seedActors, seedLocalActor, } from "./seed.test.ts"; +import { + activityTypes, + extensionValue, + typeDocument, +} from "./serving-fixture.test.ts"; const AS = "https://www.w3.org/ns/activitystreams"; const accountId = "00000000-0000-4000-8000-000000000301" as Uuid; @@ -539,6 +544,57 @@ describe("federation reads of saved activities", () => { }); } + 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 activityTypes) { + const saved = await save(env, typeDocument(type), "activity"); + const route = type === "Create" ? "creates" : "activities"; + assert.match( + saved.iri, + new RegExp(`^https://${host}/ap/${route}/`, "u"), + type, + ); + const served = publicDocument(saved.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"], extensionValue, 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, `${actorIri}/outbox`); + assert.equal(collection.body.totalItems, activityTypes.length); + const first = await get(harness.federation, `${actorIri}/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); From ce14dde139a968f86d77fb92e7bddf6428fb141f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Nov 2026 13:56:41 +0900 Subject: [PATCH 17/25] Export only what outgoing payloads use from serving @drfed/federation/serving exported serveDocument() and its ServedDocument type beside publicDocument(), though nothing outside the package used them and publicDocument() returns the same document. They are internal now, so the subpath exports publicDocument(), parseVocab(), loadActivitySnapshot() and its ActivitySnapshot type, as its changelog entry says. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to carry out a local plan that found the unused exports. Claude Code made the change. mise run build, mise run check and mise run test pass. Assisted-by: Claude Code:claude-opus-5-5 --- packages/federation/src/serving.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/federation/src/serving.ts b/packages/federation/src/serving.ts index 693630ad..7a40e4fb 100644 --- a/packages/federation/src/serving.ts +++ b/packages/federation/src/serving.ts @@ -23,12 +23,7 @@ * @module */ -export { - type ServedDocument, - parseVocab, - publicDocument, - serveDocument, -} from "./serving/document.ts"; +export { parseVocab, publicDocument } from "./serving/document.ts"; export { type ActivitySnapshot, loadActivitySnapshot, From f43e58d06af03c9547ddb28f7ac252d22522ff81 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Nov 2026 14:21:24 +0900 Subject: [PATCH 18/25] Check the activity type fixtures against the validator The first review round of the two previous commits found these gaps: - The loop over every activity type checked that a served document had no bto or bcc, but not that the saved one still had the fixture's bto, so it would pass without removing anything if the write path dropped it. It now checks the saved bto first. - The list of activity types was a copy kept in step only by a comment, so a type the validator came to accept would go untested. A test now compares it with the Fedify activity classes the validator accepts, without exporting anything new from @drfed/models. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the two previous commits with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. In the first round Fable reported these; Astra and Sol confirmed the first, and Sol confirmed the second while Astra judged it preventive. Claude Code verified both against the code, showed that the new comparison fails when a type is left out of the list, and wrote the changes. mise run check, mise run test and mise run test:postgres pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- .../federation/src/serving-fixture.test.ts | 36 +++++++++++++++---- packages/federation/src/serving.test.ts | 11 ++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/federation/src/serving-fixture.test.ts b/packages/federation/src/serving-fixture.test.ts index 6372f65d..bf3e6309 100644 --- a/packages/federation/src/serving-fixture.test.ts +++ b/packages/federation/src/serving-fixture.test.ts @@ -15,13 +15,18 @@ // 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. + * cannot be imported here; change the two together. A test compares this + * one with {@link acceptedActivityTypeIds}. */ export const activityTypes = [ "Activity", @@ -58,8 +63,30 @@ export const activityTypes = [ export type ActivityType = (typeof activityTypes)[number]; +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 activityTypes} 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(); + /** The blind recipient every fixture is addressed to. */ -const blindRecipient = "https://remote.example/users/blind"; +export const blindRecipient = "https://remote.example/users/blind"; /** * The extension value every fixture carries: an array whose order and @@ -80,10 +107,7 @@ export const extensionValue: JsonObject = { * @returns A new document. */ export const typeDocument = (type: ActivityType): JsonObject => ({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - { test: "https://example.test/vocab#" }, - ], + "@context": [AS, { test: "https://example.test/vocab#" }], type, to: [PUBLIC_IRI], bto: [blindRecipient], diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 10d76d56..06d194dc 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -62,7 +62,9 @@ import { seedLocalActor, } from "./seed.test.ts"; import { + acceptedActivityTypeIds, activityTypes, + blindRecipient, extensionValue, typeDocument, } from "./serving-fixture.test.ts"; @@ -544,6 +546,13 @@ describe("federation reads of saved activities", () => { }); } + it("covers every activity type the validator accepts", () => { + assert.deepEqual( + acceptedActivityTypeIds(), + activityTypes.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); @@ -560,6 +569,8 @@ describe("federation reads of saved activities", () => { new RegExp(`^https://${host}/ap/${route}/`, "u"), type, ); + // Saved with its blind recipient, so that serving has one to remove. + assert.deepEqual(saved.document.bto, [blindRecipient], type); const served = publicDocument(saved.document); assert.ok(served != null, type); const { status, body } = await get(harness.federation, saved.iri); From bdffbff5dbed4c06ce430319cefc417bc98a8a5b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Nov 2026 14:33:44 +0900 Subject: [PATCH 19/25] Read the stored activity back before serving it in the type loops The second review round found that both loops over every activity type checked the blind recipient only in the document the write returned, which is the prepared one, not what the database stored. A storage path that dropped bto on PGlite or PostgreSQL would have left both the expected and the served document without it. Each loop now reads the stored activity back with loadActivitySnapshot(), checks that it equals the saved document and keeps the fixture's bto, and expects the served document to be the public form of that stored one. https://github.com/fedify-dev/drfed/issues/153 The contributor asked Claude Code to run a review loop on the commits with three reviewers: Claude Fable 5.1 in Claude Code, and GPT-6 Astra and GPT-6 Sol in Codex. In the second round Sol reported this for the PostgreSQL loop, Fable and Astra confirmed it, and Astra noted that the PGlite loop had the same gap. Claude Code verified it against the code and wrote the change. mise run check, mise run test and mise run test:postgres pass. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- .../src/local-write-postgres.test.ts | 18 +++++++++++++++--- packages/federation/src/serving.test.ts | 9 ++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/federation/src/local-write-postgres.test.ts b/packages/federation/src/local-write-postgres.test.ts index 56dfb953..9fd7ba9a 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -32,7 +32,10 @@ import { commitLocalWrite, previewLocalWrite, } from "@drfed/federation/local-write"; -import { publicDocument } from "@drfed/federation/serving"; +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"; @@ -49,7 +52,11 @@ import { seedActors, seedLocalActor, } from "./seed.test.ts"; -import { activityTypes, typeDocument } from "./serving-fixture.test.ts"; +import { + activityTypes, + blindRecipient, + typeDocument, +} from "./serving-fixture.test.ts"; // This service is an isolated, author-owned test cluster. // oxlint-disable-next-line node/no-process-env @@ -440,9 +447,14 @@ describe("federation reads on PostgreSQL", () => { 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, [blindRecipient], type); const { status, body } = await get(saved!.iri); assert.equal(status, 200, type); - assert.deepEqual(body, publicDocument(saved!.document), type); + assert.deepEqual(body, publicDocument(stored.document), type); iris.push(saved!.iri); } const ids = (body: Record) => diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index 06d194dc..a36d7c29 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -569,9 +569,12 @@ describe("federation reads of saved activities", () => { new RegExp(`^https://${host}/ap/${route}/`, "u"), type, ); - // Saved with its blind recipient, so that serving has one to remove. - assert.deepEqual(saved.document.bto, [blindRecipient], type); - const served = publicDocument(saved.document); + // 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, [blindRecipient], 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); From 9f01f847988038e2fef645f1d338d15b9339b583 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sat, 7 Nov 2026 17:07:56 +0900 Subject: [PATCH 20/25] Add PR links to changelog --- CHANGES.md | 7 ++++--- changes.d/drfed/serve-saved-activities.md | 3 ++- changes.d/javascript/federation-serving.md | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bbbd7f78..976f408a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,7 +17,7 @@ To be released. 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] by ChanHaeng Lee\] + [[#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 @@ -41,6 +41,7 @@ To be released. [#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 @@ -88,12 +89,12 @@ To be released. `publicDocument()`, the serialization federation reads use for saved documents, `parseVocab()`, which parses such a document into a Fedify vocabulary object, and `loadActivitySnapshot()`. - [[#153] by ChanHaeng Lee\] + [[#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] by ChanHaeng Lee\] + [[#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/changes.d/drfed/serve-saved-activities.md b/changes.d/drfed/serve-saved-activities.md index 89b7c278..c999a22d 100644 --- a/changes.d/drfed/serve-saved-activities.md +++ b/changes.d/drfed/serve-saved-activities.md @@ -1,6 +1,7 @@ --- 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 @@ -11,4 +12,4 @@ links: 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] by ChanHaeng Lee] + [[#153], [#177] by ChanHaeng Lee] diff --git a/changes.d/javascript/federation-serving.md b/changes.d/javascript/federation-serving.md index 3998a657..5daa1995 100644 --- a/changes.d/javascript/federation-serving.md +++ b/changes.d/javascript/federation-serving.md @@ -1,15 +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] by ChanHaeng Lee] + [[#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] by ChanHaeng Lee] + [[#153], [#177] by ChanHaeng Lee] From 2b224d2762b756b5c80d10c9a644bcea97ef0968 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 8 Nov 2026 22:49:44 +0900 Subject: [PATCH 21/25] Tighten serving test assertions from review Compare IRI prefixes with startsWith() instead of regular expressions built from the instance host, whose unescaped dots matched any character. Order the extension value in the PostgreSQL read test so that jsonb would reorder its keys. The previous order was already the one jsonb stores, so a switch away from the json column went unnoticed. Compare a legacy Create's response before and after an edit of its object that changes both its content and its recipients, so that the test shows an edit does not rewrite a past activity response. The user asked Claude Code to organize the review comments into plans, approved them, and had it apply them; Claude Code wrote these changes. Claude Code (Fable) and Codex (Astra, Sol) reviewed them independently and reported no issue in them. The build, mise run check and mise run test passed. The PostgreSQL test was not run, as no test cluster was set; PGlite showed that jsonb reorders the new key order and json keeps it. https://github.com/fedify-dev/drfed/pull/177#discussion_r4500804874 https://github.com/fedify-dev/drfed/pull/177#pullrequestreview-5811030992 https://github.com/fedify-dev/drfed/pull/177#issuecomment-6704273889 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- .../src/local-write-postgres.test.ts | 5 ++-- packages/federation/src/serving.test.ts | 23 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/federation/src/local-write-postgres.test.ts b/packages/federation/src/local-write-postgres.test.ts index 9fd7ba9a..86551980 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -379,7 +379,7 @@ describe("federation reads on PostgreSQL", () => { to: [PUBLIC_IRI], bcc: ["https://remote.example/users/blind"], object: "https://remote.example/notes/1", - mood: { text: '안녕 "🙂" \\ é', zeta: 1, alpha: [true, null] }, + mood: { zeta: 1, alpha: [true, null], text: '안녕 "🙂" \\ é' }, }, resources: [{ path: [], kind: "activity" }], publish: true, @@ -387,10 +387,11 @@ describe("federation reads on PostgreSQL", () => { 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), [ - "text", "zeta", "alpha", + "text", ]); const [noteResource, create] = await write(env, { operation: "create", diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index a36d7c29..be963aa9 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -435,7 +435,10 @@ describe("federation reads of saved activities", () => { ], route: "creates", check: (body) => - assert.match(String(body.object), new RegExp(`^${actorIri}/`, "u")), + assert.ok( + String(body.object).startsWith(`${actorIri}/`), + String(body.object), + ), }, { name: "Undo of a new Like of a remote Note", @@ -528,9 +531,9 @@ describe("federation reads of saved activities", () => { const saved = await save(env, document, "activity", { ...(nested == null ? {} : { nested }), }); - assert.match( + assert.ok( + saved.iri.startsWith(`https://${host}/ap/${route}/`), saved.iri, - new RegExp(`^https://${host}/ap/${route}/`, "u"), ); const { status, body } = await get(harness.federation, saved.iri); assert.equal(status, 200); @@ -564,10 +567,9 @@ describe("federation reads of saved activities", () => { for (const type of activityTypes) { const saved = await save(env, typeDocument(type), "activity"); const route = type === "Create" ? "creates" : "activities"; - assert.match( - saved.iri, - new RegExp(`^https://${host}/ap/${route}/`, "u"), - type, + 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); @@ -890,14 +892,19 @@ describe("federation reads of saved objects and their history", () => { assert.ok(!isLocalWriteError(legacy)); const legacyIri = `${actorIri}/${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.equal(legacyCreate.status, 200); + assert.deepEqual(legacyCreate, original); // A legacy Create is served at its own route only. assert.equal( ( From 8aca83a236dd3d12859683cfabb21f5c1a7deb34 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 8 Nov 2026 22:50:30 +0900 Subject: [PATCH 22/25] Read self-referential Links in extension values describeDocument() counted an extension node object's own identifiers when it decided whether its id was used elsewhere, so a Link whose href is its own id was left uninterpreted. Such a document was saved but answered 404 and was left out of outbox pages and the counter, although the same Link was served in object. Only uses by other documents and node objects count now, counted so that the check stays linear in a long list of the node's own references. deniedClaims() reported a path once for every claim that reached it, so a Link under attributedTo whose id and href name one local IRI that is no actor produced three identical href diagnostics. Each denied path is reported once now, keeping the distinct id and href. The user approved plans for these review comments and had Claude Code apply them. Codex (Astra) found that the first version, which looked up each use among the node's own identifiers, was quadratic in them; Claude Code (Fable) and Codex (Sol) confirmed it, and the check counts instead. The new tests fail on the previous code and pass with this change. The build, mise run check and mise run test passed, and a node with 50,000 references to its own id is described in 29 ms. https://github.com/fedify-dev/drfed/pull/177#discussion_r4505953907 https://github.com/fedify-dev/drfed/pull/177#discussion_r4505953910 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/internals/federation-reads.md | 5 ++-- packages/federation/src/local-write.test.ts | 26 +++++++++++++++++++ .../federation/src/local-write/prepare.ts | 11 +++++--- packages/federation/src/serving.test.ts | 22 ++++++++++++++++ packages/models/README.md | 3 ++- packages/models/src/document.test.ts | 14 ++++++++++ packages/models/src/document.ts | 12 +++++++-- 7 files changed, 85 insertions(+), 8 deletions(-) diff --git a/docs/internals/federation-reads.md b/docs/internals/federation-reads.md index 40662ede..0e4702a2 100644 --- a/docs/internals/federation-reads.md +++ b/docs/internals/federation-reads.md @@ -103,8 +103,9 @@ actor, and Public `to` or `cc` addressing in the resource's current 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, is one node with what uses it, - and is not read apart either. Language maps are read as strings, and + 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. diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index cc8a6550..67374868 100644 --- a/packages/federation/src/local-write.test.ts +++ b/packages/federation/src/local-write.test.ts @@ -771,6 +771,32 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { [["unauthorized_local_identity", path]], ); } + // A Link whose href is its own id claims it through both; each path + // is denied once. + const forged = "https://test-instance.drfed.org/users/x/forged"; + 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, diff --git a/packages/federation/src/local-write/prepare.ts b/packages/federation/src/local-write/prepare.ts index ea13b50b..68237536 100644 --- a/packages/federation/src/local-write/prepare.ts +++ b/packages/federation/src/local-write/prepare.ts @@ -310,7 +310,7 @@ const isInstanceIri = (iri: string, rootOrigin: URL): boolean => * 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. + * @returns The denied claims' paths, each once. */ export async function deniedClaims( nodes: readonly DocumentNode[], @@ -367,10 +367,15 @@ export async function deniedClaims( 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[] { diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index be963aa9..e69403b8 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -1184,6 +1184,28 @@ describe("embedded documents and implicit contexts", () => { }); }); + it("reads a node object in an extension value whose own href is its id", async () => { + await withFederation(async (harness) => { + const env = await setUp(harness); + const self = "https://remote.example/x"; + 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); diff --git a/packages/models/README.md b/packages/models/README.md index 4cc28318..e7db9f98 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -103,7 +103,8 @@ 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, since it is then not read apart. A value or a list +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 diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 39b54dc7..6f7080eb 100644 --- a/packages/models/src/document.test.ts +++ b/packages/models/src/document.test.ts @@ -4429,6 +4429,17 @@ describe("describeDocument() semantic view", () => { { 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( @@ -4439,6 +4450,9 @@ describe("describeDocument() semantic view", () => { { 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 diff --git a/packages/models/src/document.ts b/packages/models/src/document.ts index a3a24012..90edf36a 100644 --- a/packages/models/src/document.ts +++ b/packages/models/src/document.ts @@ -2607,7 +2607,8 @@ export interface ExtensionNode { * 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, so that it is not read apart. An + * 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. */ @@ -3061,6 +3062,8 @@ export function describeDocument( // 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, @@ -3069,7 +3072,12 @@ export function describeDocument( ); const extensionNodes = described.map(({ node }): ExtensionNode => { const id = node.identifiers.find(({ property }) => property === "id"); - const joined = id != null && (uses.get(id.iri)?.length ?? 0) > 1; + // 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)), From 786183bf2e355c5be11fb8839c30e68280144d85 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 8 Nov 2026 22:51:09 +0900 Subject: [PATCH 23/25] Parse outbox pages in a test for fedify#1394 An activity whose context redefines a term protected by the context of Fedify's collection pages, such as proof, is served on its own, but the outbox page that embeds it is invalid JSON-LD: it answers 200 OK, yet OrderedCollectionPage.fromJsonLd() fails with "protected term redefinition", and no JSON-LD client can read the page's other activities either. The cause is upstream context composition. The test parses such a page and is marked to fail with that error until the dependency includes the upstream fix. It then fails until the mark is removed, so the bump that the tracking issue asks for before 0.2.0 cannot leave it behind. The user approved the plan for this review comment, filed the tracking issue from a draft by Claude Code, and had Claude Code write the test. Claude Code (Fable) and Codex (Astra, Sol) reviewed it independently and reported no issue in it. Without the mark, the test fails with "protected term redefinition"; with it, mise run test passes, and mise run check passed. https://github.com/fedify-dev/drfed/pull/177#discussion_r4505953902 https://github.com/fedify-dev/drfed/issues/178 https://github.com/fedify-dev/fedify/issues/1394 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- packages/federation/src/serving.test.ts | 51 ++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index e69403b8..a22a8290 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -48,7 +48,7 @@ import { } from "@drfed/models/resource-revision"; import { type Uuid, uuidV7 } from "@drfed/models/uuid"; import type { Federation } from "@fedify/fedify"; -import { Like, getTypeId } from "@fedify/vocab"; +import { Like, OrderedCollectionPage, getTypeId } from "@fedify/vocab"; import { describe, it } from "@logtape/testing-node/autoload"; import { eq } from "drizzle-orm"; @@ -1615,6 +1615,55 @@ describe("outboxes of saved and legacy activities", () => { }); }); + // 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, + `${actorIri}/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); From 14c17d9e7554de26747da0f54705924320f7e683 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 8 Nov 2026 23:22:51 +0900 Subject: [PATCH 24/25] Name constants in capital letters Constants in the files this pull request changes are now named in CAPITAL_LETTERS and declared at the top of their file, or at the top of the closure they belong to. A constant here is a value fixed when the code is written: a literal, a template literal of other constants, or an immutable collection of such values. Functions, runtime values, mutable state and test fixture literals keep their names. @drfed/models/document exports DIAGNOSTIC_REASONS and ADDRESSING_PROPERTIES instead of diagnosticReasons and addressingProperties; neither name was in 0.1.0. The user set this convention and its scope after a review asked for uppercase constants near the top, and had Claude Code apply it. Claude Code renamed the identifiers with a script that skips strings, member accesses and object keys and expands shorthand properties, then moved the declarations. Reversing the renames shows that only formatting, import order, declaration order and two new comments differ from the previous commit. Claude Code (Fable) and Codex (Astra, Sol) reviewed the change independently and reported no issue. The build, mise run check and mise run test passed. https://github.com/fedify-dev/drfed/pull/177#pullrequestreview-5817618794 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- docs/.vitepress/config.mts | 20 +- fixtures/document-validation/initial.json | 2 +- packages/federation/src/federation.test.ts | 172 ++-- .../src/local-write-postgres.test.ts | 56 +- packages/federation/src/local-write.test.ts | 183 ++-- .../federation/src/local-write/existing.ts | 8 +- .../federation/src/local-write/prepare.ts | 30 +- .../federation/src/serving-fixture.test.ts | 38 +- packages/federation/src/serving.test.ts | 276 +++--- packages/federation/src/serving/document.ts | 26 +- packages/federation/src/serving/resource.ts | 32 +- packages/federation/src/serving/vocab.ts | 4 +- packages/models/README.md | 2 +- packages/models/src/document.test.ts | 792 +++++++++--------- packages/models/src/document.ts | 141 ++-- 15 files changed, 902 insertions(+), 880 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e2eec63c..1d9a1c7c 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", diff --git a/fixtures/document-validation/initial.json b/fixtures/document-validation/initial.json index 24aafc8f..3f4f6f29 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/src/federation.test.ts b/packages/federation/src/federation.test.ts index ff4b06a4..9b82c40e 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 }, ); @@ -522,7 +524,7 @@ describe("ActivityPub general activities", () => { 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,7 +590,7 @@ 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); @@ -600,8 +602,8 @@ describe("ActivityPub outbox", () => { "--1", ]) { const invalid = await federation.fetch( - new Request(`${actorIri}/outbox?cursor=${cursor}`, { - headers: accept, + new Request(`${ACTOR_IRI}/outbox?cursor=${cursor}`, { + headers: ACCEPT, }), { contextData: undefined }, ); @@ -638,13 +640,13 @@ describe("ActivityPub outbox", () => { } 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); const order = objects .map((_, index) => index) @@ -685,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); @@ -701,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); @@ -727,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); @@ -737,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 ?? [], [], ); @@ -756,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) { @@ -771,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]; @@ -790,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), @@ -812,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), @@ -823,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 [ @@ -859,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); } }); }); @@ -889,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, @@ -922,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, @@ -958,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), []); @@ -983,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, @@ -1009,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; @@ -1045,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")), @@ -1081,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(); @@ -1105,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 86551980..7843d2c6 100644 --- a/packages/federation/src/local-write-postgres.test.ts +++ b/packages/federation/src/local-write-postgres.test.ts @@ -53,17 +53,17 @@ import { seedLocalActor, } from "./seed.test.ts"; import { - activityTypes, - blindRecipient, + 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; @@ -95,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, @@ -130,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, }, @@ -169,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; } @@ -190,7 +190,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { connections.map((connection) => commitLocalWrite( { ...env, db: connection.db }, - principal, + PRINCIPAL, request, { previewToken: token, @@ -228,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)), @@ -248,7 +248,7 @@ describe("commitLocalWrite() on PostgreSQL", () => { connections.map((connection, index) => commitLocalWrite( { ...env, db: connection.db }, - principal, + PRINCIPAL, requests[index]!, { previewToken: tokens[index]! }, ), @@ -289,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 }, ); @@ -302,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; @@ -326,9 +326,9 @@ async function write( env: LocalWriteEnvironment, request: LocalWriteRequest, ): 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: () => Promise.resolve(), }); @@ -358,7 +358,7 @@ async function fetchDocument( } describe("federation reads on PostgreSQL", () => { - const actorIri = `https://test-instance.drfed.org/users/${localActorId}`; + const ACTOR_IRI = `https://test-instance.drfed.org/users/${localActorId}`; it( "serves saved documents as stored in JSON columns, and hides deleted content", @@ -421,8 +421,8 @@ describe("federation reads on PostgreSQL", () => { 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(`${actorIri}/outbox`); - const page = await get(`${actorIri}/outbox?cursor=`); + 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), @@ -440,7 +440,7 @@ describe("federation reads on PostgreSQL", () => { await withDatabase(async (_url, env) => { const get = (iri: string) => fetchDocument(env, iri); const iris: string[] = []; - for (const type of activityTypes) { + for (const type of ACTIVITY_TYPES) { const [saved] = await write(env, { operation: "create", actorId: localActorId, @@ -452,7 +452,7 @@ describe("federation reads on PostgreSQL", () => { 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, [blindRecipient], 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); @@ -460,9 +460,9 @@ describe("federation reads on PostgreSQL", () => { } const ids = (body: Record) => (body.orderedItems as JsonObject[]).map(({ id }) => id); - const collection = await get(`${actorIri}/outbox`); - assert.equal(collection.body.totalItems, activityTypes.length); - const first = await get(`${actorIri}/outbox?cursor=`); + 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( diff --git a/packages/federation/src/local-write.test.ts b/packages/federation/src/local-write.test.ts index 67374868..4e53e3f3 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); @@ -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( @@ -655,6 +654,9 @@ 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) => @@ -697,7 +699,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ] as const) { const outcome = await previewLocalWrite( env, - principal, + PRINCIPAL, quoting(quoted), ); assert.ok(!outcome.ok); @@ -711,16 +713,14 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ); } // A reference names what a Link embedded elsewhere with its id does. - const link = "https://remote.example/links/1"; - const victim = "https://other.drfed.org/users/victim"; for (const [request, path] of [ [ create( { "@context": AS, type: "Like", - object: { type: "Note", content: "forged", attributedTo: link }, - target: { type: "Link", id: link, href: victim }, + object: { type: "Note", content: "forged", attributedTo: LINK }, + target: { type: "Link", id: LINK, href: VICTIM }, }, [{ path: [], kind: "activity" }], ), @@ -733,8 +733,8 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { type: "Like", object: "https://x.example/", "ex:quoted": [ - { type: "Note", attributedTo: link }, - { id: link, type: "Link", href: victim }, + { type: "Note", attributedTo: LINK }, + { id: LINK, type: "Link", href: VICTIM }, ], } as never, [{ path: [], kind: "activity" }], @@ -751,7 +751,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { object: "https://x.example/", "ex:quoted": [ { type: "Note", attributedTo: author }, - { id: "_:author", type: "Link", href: victim }, + { id: "_:author", type: "Link", href: VICTIM }, ], } as never, [{ path: [], kind: "activity" }], @@ -760,7 +760,7 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { ] as const, ), ] as const) { - const outcome = await previewLocalWrite(env, principal, request); + const outcome = await previewLocalWrite(env, PRINCIPAL, request); assert.ok(!outcome.ok, JSON.stringify(request.document)); assert.deepEqual( outcome.error.type === "invalid_document" @@ -773,13 +773,12 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { } // A Link whose href is its own id claims it through both; each path // is denied once. - const forged = "https://test-instance.drfed.org/users/x/forged"; const self = await previewLocalWrite( env, - principal, + PRINCIPAL, quoting({ type: "Note", - attributedTo: { type: "Link", id: forged, href: forged }, + attributedTo: { type: "Link", id: FORGED, href: FORGED }, }), ); assert.ok(!self.ok); @@ -811,19 +810,19 @@ describe("previewLocalWrite() and commitLocalWrite()", () => { 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" @@ -836,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(); @@ -850,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); @@ -865,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); @@ -882,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("."); @@ -897,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); @@ -913,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); @@ -922,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); @@ -948,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")), }), @@ -966,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); @@ -1030,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)); @@ -1038,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"); @@ -1067,7 +1066,7 @@ async function seedLegacyNote( id, activityId, actorId: localActorId, - iri: `${actorIri}/${id}`, + iri: `${ACTOR_IRI}/${id}`, type: "Note", contentHtml: "

First

", language: "en", @@ -1096,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) { @@ -1112,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 }), }); @@ -1197,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, [ @@ -1226,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( @@ -1244,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 }, ); @@ -1280,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, @@ -1294,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 }, ); @@ -1304,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, @@ -1322,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", @@ -1351,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"], @@ -1427,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, @@ -1440,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", @@ -1520,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); @@ -1534,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, @@ -1598,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 aff342dc..5c590757 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 68237536..86dcd522 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,7 +292,7 @@ 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; @@ -394,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.`, }); @@ -454,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) { @@ -517,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/serving-fixture.test.ts b/packages/federation/src/serving-fixture.test.ts index bf3e6309..3783af10 100644 --- a/packages/federation/src/serving-fixture.test.ts +++ b/packages/federation/src/serving-fixture.test.ts @@ -28,7 +28,7 @@ const AS = "https://www.w3.org/ns/activitystreams"; * cannot be imported here; change the two together. A test compares this * one with {@link acceptedActivityTypeIds}. */ -export const activityTypes = [ +export const ACTIVITY_TYPES = [ "Activity", "IntransitiveActivity", "Accept", @@ -61,7 +61,21 @@ export const activityTypes = [ "View", ] as const; -export type ActivityType = (typeof activityTypes)[number]; +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 || @@ -70,7 +84,7 @@ const isActivityClass = (value: unknown): value is typeof vocabulary.Activity => /** * Lists the type IRIs of the Fedify activity classes that the validator - * accepts, so that a test can tell when {@link activityTypes} falls behind + * accepts, so that a test can tell when {@link ACTIVITY_TYPES} falls behind * it. * @returns The IRIs, sorted. */ @@ -85,20 +99,6 @@ export const acceptedActivityTypeIds = (): string[] => ) .toSorted(); -/** The blind recipient every fixture is addressed to. */ -export const blindRecipient = "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 extensionValue: JsonObject = { - ordered: [2, 1, 2], - enabled: false, - empty: null, -}; - /** * Makes the document saved for an activity type: Public, with a blind * recipient and an extension property, and nothing a type requires, since @@ -110,6 +110,6 @@ export const typeDocument = (type: ActivityType): JsonObject => ({ "@context": [AS, { test: "https://example.test/vocab#" }], type, to: [PUBLIC_IRI], - bto: [blindRecipient], - "test:extension": structuredClone(extensionValue), + bto: [BLIND_RECIPIENT], + "test:extension": structuredClone(EXTENSION_VALUE), }); diff --git a/packages/federation/src/serving.test.ts b/packages/federation/src/serving.test.ts index a22a8290..64ae5ea9 100644 --- a/packages/federation/src/serving.test.ts +++ b/packages/federation/src/serving.test.ts @@ -62,33 +62,33 @@ import { seedLocalActor, } from "./seed.test.ts"; import { + ACTIVITY_TYPES, + BLIND_RECIPIENT, + EXTENSION_VALUE, acceptedActivityTypeIds, - activityTypes, - blindRecipient, - extensionValue, typeDocument, } from "./serving-fixture.test.ts"; const AS = "https://www.w3.org/ns/activitystreams"; -const accountId = "00000000-0000-4000-8000-000000000301" as Uuid; -const principal = { accountId }; -const host = "test-instance.drfed.org"; -const actorIri = `https://${host}/users/${localActorId}`; -const blind = "https://remote.example/users/blind"; -const bob = "https://remote.example/users/bob"; +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: accountId, + id: ACCOUNT_ID, email: "member@example.com", name: "Member", created, }); await harness.db.insert(schema.instanceMembers).values({ - accountId, + accountId: ACCOUNT_ID, instanceId: localInstanceId, accepted: created, created, @@ -109,9 +109,9 @@ async function write( env: LocalWriteEnvironment, request: LocalWriteRequest, ): 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: () => Promise.resolve(), }); @@ -201,7 +201,7 @@ async function misdirect( scenario: "wrongHost" | "otherInstance" | "deletedActor", ): Promise<(iri: string) => string> { if (scenario === "wrongHost") { - return (iri) => iri.replace(host, "wrong.example"); + return (iri) => iri.replace(HOST, "wrong.example"); } if (scenario === "otherInstance") { const id = uuidV7(); @@ -214,7 +214,7 @@ async function misdirect( created, host: "other-instance.drfed.org", }); - return (iri) => iri.replace(host, "other-instance.drfed.org"); + return (iri) => iri.replace(HOST, "other-instance.drfed.org"); } await harness.db .update(schema.actors) @@ -243,10 +243,10 @@ describe("publicDocument()", () => { id: "https://a.example/notes/1", type: "Note", content: "x", - to: [PUBLIC_IRI, bob], - bto: [blind], - cc: [`${actorIri}/followers`], - bcc: blind, + to: [PUBLIC_IRI, BOB], + bto: [BLIND], + cc: [`${ACTOR_IRI}/followers`], + bcc: BLIND, audience: "https://a.example/groups/1", }; const copy = structuredClone(document); @@ -255,8 +255,8 @@ describe("publicDocument()", () => { id: "https://a.example/notes/1", type: "Note", content: "x", - to: [PUBLIC_IRI, bob], - cc: [`${actorIri}/followers`], + to: [PUBLIC_IRI, BOB], + cc: [`${ACTOR_IRI}/followers`], audience: "https://a.example/groups/1", }); assert.deepEqual(document, copy); @@ -267,19 +267,19 @@ describe("publicDocument()", () => { "@context": AS, id: "https://a.example/activities/1", type: "Undo", - actor: actorIri, + actor: ACTOR_IRI, to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], object: { id: "https://a.example/activities/2", type: "Like", - actor: actorIri, - bto: [blind], + actor: ACTOR_IRI, + bto: [BLIND], object: { id: "https://a.example/notes/1", type: "Note", content: "x", - bcc: [blind], + bcc: [BLIND], }, }, }; @@ -289,12 +289,12 @@ describe("publicDocument()", () => { "@context": AS, id: "https://a.example/activities/1", type: "Undo", - actor: actorIri, + actor: ACTOR_IRI, to: [PUBLIC_IRI], object: { id: "https://a.example/activities/2", type: "Like", - actor: actorIri, + actor: ACTOR_IRI, object: { id: "https://a.example/notes/1", type: "Note", @@ -318,8 +318,8 @@ describe("publicDocument()", () => { type: "Note", content: "x", to: [PUBLIC_IRI], - hidden: [blind], - secret: blind, + hidden: [BLIND], + secret: BLIND, }); assert.deepEqual(result, { "@context": context, @@ -340,16 +340,16 @@ describe("publicDocument()", () => { "@context": [...context], id: "https://a.example/activities/1", type: "Like", - actor: actorIri, + actor: ACTOR_IRI, object: "https://remote.example/notes/1", - mood: { bcc: [blind], deep: [1, "x", null, { bto: blind }], literal }, - bcc: [blind], + 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: actorIri, + actor: ACTOR_IRI, object: "https://remote.example/notes/1", mood: { deep: [1, "x", null, {}], literal }, }); @@ -366,13 +366,13 @@ describe("publicDocument()", () => { publicDocument({ ...document, "@context": listed, - mood: { hidden: [blind], typed }, + mood: { hidden: [BLIND], typed }, }), { "@context": listed, id: "https://a.example/activities/1", type: "Like", - actor: actorIri, + actor: ACTOR_IRI, object: "https://remote.example/notes/1", mood: { typed }, }, @@ -381,7 +381,7 @@ describe("publicDocument()", () => { assert.equal( publicDocument({ ...document, - mood: { "@context": { secret: "as:bcc" }, secret: [blind] }, + mood: { "@context": { secret: "as:bcc" }, secret: [BLIND] }, }), null, ); @@ -394,7 +394,7 @@ describe("publicDocument()", () => { id: "https://a.example/notes/1", type: "Note", content: "x", - bcc: [blind], + bcc: [BLIND], }), null, ); @@ -415,8 +415,8 @@ describe("federation reads of saved activities", () => { "@context": AS, type: "Create", to: [PUBLIC_IRI], - bcc: [blind], - object: { type: "Note", content: "x", to: [PUBLIC_IRI], bcc: [blind] }, + bcc: [BLIND], + object: { type: "Note", content: "x", to: [PUBLIC_IRI], bcc: [BLIND] }, }, nested: [{ path: ["object"], kind: "object" }], route: "creates", @@ -436,7 +436,7 @@ describe("federation reads of saved activities", () => { route: "creates", check: (body) => assert.ok( - String(body.object).startsWith(`${actorIri}/`), + String(body.object).startsWith(`${ACTOR_IRI}/`), String(body.object), ), }, @@ -461,18 +461,18 @@ describe("federation reads of saved activities", () => { document: { "@context": AS, type: "Accept", - to: [bob], + to: [BOB], cc: [PUBLIC_IRI], object: { id: "https://remote.example/follows/1", type: "Follow", - actor: bob, - object: actorIri, + actor: BOB, + object: ACTOR_IRI, }, }, route: "activities", check: (body) => - assert.equal((body.object as { actor: unknown }).actor, bob), + assert.equal((body.object as { actor: unknown }).actor, BOB), }, { name: "Add to a collection target", @@ -481,10 +481,10 @@ describe("federation reads of saved activities", () => { type: "Add", to: [PUBLIC_IRI], object: "https://remote.example/notes/1", - target: `${actorIri}/featured`, + target: `${ACTOR_IRI}/featured`, }, route: "activities", - check: (body) => assert.equal(body.target, `${actorIri}/featured`), + check: (body) => assert.equal(body.target, `${ACTOR_IRI}/featured`), }, { name: "Arrive without an object", @@ -506,7 +506,7 @@ describe("federation reads of saved activities", () => { "@context": [AS, { ex: "https://ext.example/ns#", mood: "ex:mood" }], type: "Like", to: [PUBLIC_IRI], - bto: [blind], + bto: [BLIND], object: "https://remote.example/notes/1", mood: { deep: [1, "x"] }, }, @@ -532,7 +532,7 @@ describe("federation reads of saved activities", () => { ...(nested == null ? {} : { nested }), }); assert.ok( - saved.iri.startsWith(`https://${host}/ap/${route}/`), + saved.iri.startsWith(`https://${HOST}/ap/${route}/`), saved.iri, ); const { status, body } = await get(harness.federation, saved.iri); @@ -552,7 +552,7 @@ describe("federation reads of saved activities", () => { it("covers every activity type the validator accepts", () => { assert.deepEqual( acceptedActivityTypeIds(), - activityTypes.map((type) => `${AS}#${type}`).toSorted(), + ACTIVITY_TYPES.map((type) => `${AS}#${type}`).toSorted(), ); }); @@ -560,22 +560,22 @@ describe("federation reads of saved activities", () => { await withFederation(async (harness) => { const env = await setUp(harness); const ctx = harness.federation.createContext( - new URL(`https://${host}/`), + new URL(`https://${HOST}/`), undefined, ); const iris: string[] = []; - for (const type of activityTypes) { + 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}/`), + 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, [blindRecipient], 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); @@ -583,7 +583,7 @@ describe("federation reads of saved activities", () => { assert.deepEqual(body, served, type); assert.equal(body.type, type); assert.deepEqual(blindKeys(body), [], type); - assert.deepEqual(body["test:extension"], extensionValue, type); + assert.deepEqual(body["test:extension"], EXTENSION_VALUE, type); const other = saved.iri.replace( `/ap/${route}/`, route === "creates" ? "/ap/activities/" : "/ap/creates/", @@ -598,9 +598,12 @@ describe("federation reads of saved activities", () => { // 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, `${actorIri}/outbox`); - assert.equal(collection.body.totalItems, activityTypes.length); - const first = await get(harness.federation, `${actorIri}/outbox?cursor=`); + 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); @@ -620,7 +623,7 @@ describe("federation reads of saved activities", () => { assert.deepEqual(snapshot?.document, saved.document); const served = publicDocument(snapshot!.document)!; const ctx = harness.federation.createContext( - new URL(`https://${host}/`), + new URL(`https://${HOST}/`), undefined, ); const vocab = await parseVocab(ctx, served); @@ -653,7 +656,7 @@ describe("federation reads of saved activities", () => { type: "Like", object: "https://remote.example/notes/1", to: [ - scenario === "followers" ? `${actorIri}/followers` : PUBLIC_IRI, + scenario === "followers" ? `${ACTOR_IRI}/followers` : PUBLIC_IRI, ], }, "activity", @@ -661,7 +664,7 @@ describe("federation reads of saved activities", () => { ); const iri = scenario === "malformed" - ? `https://${host}/ap/activities/bad` + ? `https://${HOST}/ap/activities/bad` : scenario === "unpublished" || scenario === "followers" ? saved.iri : (await misdirect(harness, scenario))(saved.iri); @@ -680,7 +683,7 @@ describe("federation reads of saved objects and their history", () => { type: "Note", content: "x", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], }; const saved = await save(env, note, "object"); const { status, body } = await get(harness.federation, saved.iri); @@ -692,7 +695,7 @@ describe("federation reads of saved objects and their history", () => { assert.equal((await get(harness.federation, elsewhere)).status, 404); const followers = await save( env, - { ...note, to: [`${actorIri}/followers`] }, + { ...note, to: [`${ACTOR_IRI}/followers`] }, "object", ); assert.equal((await get(harness.federation, followers.iri)).status, 404); @@ -701,21 +704,21 @@ describe("federation reads of saved objects and their history", () => { 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); - const otherId = "00000000-0000-4000-8000-000000000203" as Uuid; await harness.db .insert(schema.localActors) - .values({ id: otherId, avatar: "avatar.png", header: "header.png" }); + .values({ id: OTHER_ID, avatar: "avatar.png", header: "header.png" }); await seedActors( harness.db, { - id: otherId, - localId: otherId, + id: OTHER_ID, + localId: OTHER_ID, instanceId: localInstanceId, type: "Person", username: "bob", - iri: `https://${host}/users/${otherId}`, - inboxUrl: `https://${host}/users/${otherId}/inbox`, + iri: `https://${HOST}/users/${OTHER_ID}`, + inboxUrl: `https://${HOST}/users/${OTHER_ID}/inbox`, created, }, { keys: false }, @@ -730,7 +733,7 @@ describe("federation reads of saved objects and their history", () => { ] 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, otherId); + const elsewhere = saved.iri.replace(localActorId, OTHER_ID); assert.equal((await get(harness.federation, elsewhere)).status, 404); } }); @@ -772,7 +775,7 @@ describe("federation reads of saved objects and their history", () => { type: "Note", content: "A", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], }, }, "activity", @@ -823,7 +826,7 @@ describe("federation reads of saved objects and their history", () => { type: "Note", content: "A", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], }, }, "activity", @@ -867,7 +870,7 @@ describe("federation reads of saved objects and their history", () => { "@context": AS, type: "Note", content: "x", - to: [`${actorIri}/followers`], + to: [`${ACTOR_IRI}/followers`], }, "object", ); @@ -879,7 +882,7 @@ describe("federation reads of saved objects and their history", () => { 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, { + const legacy = await createLegacyObject(env, PRINCIPAL, { actorId: localActorId, type: "Note", contentHtml: "

A

", @@ -887,21 +890,21 @@ describe("federation reads of saved objects and their history", () => { summary: null, language: null, sensitive: false, - addressing: { to: [PUBLIC_IRI], bcc: [blind] }, + addressing: { to: [PUBLIC_IRI], bcc: [BLIND] }, }); assert.ok(!isLocalWriteError(legacy)); - const legacyIri = `${actorIri}/${legacy.id}`; - const createIri = `https://${host}/ap/creates/${legacy.activityId}`; + 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], + cc: [BOB], })); const object = await get(harness.federation, legacyIri); assert.equal(object.body.content, "

B

"); - assert.equal(object.body.cc, bob); + 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); @@ -933,8 +936,8 @@ describe("federation reads of saved objects and their history", () => { describe("embedded documents and implicit contexts", () => { const outboxIds = async (federation: Federation) => { - const collection = await get(federation, `${actorIri}/outbox`); - const page = await get(federation, `${actorIri}/outbox?cursor=`); + 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); @@ -949,7 +952,7 @@ describe("embedded documents and implicit contexts", () => { "@context": AS, type: "Create", to: [PUBLIC_IRI], - object: { type: "Note", content: "secret", to: [bob] }, + object: { type: "Note", content: "secret", to: [BOB] }, }, "activity", { nested: [{ path: ["object"], kind: "object" }] }, @@ -965,7 +968,7 @@ describe("embedded documents and implicit contexts", () => { to: [PUBLIC_IRI], object: { type: "Like", - object: { type: "Note", content: "secret", cc: [bob] }, + object: { type: "Note", content: "secret", cc: [BOB] }, }, }, "activity", @@ -1002,7 +1005,7 @@ describe("embedded documents and implicit contexts", () => { [[{ type: "Collection", id: PUBLIC_IRI }], 200], [{ type: "Collection", id: "as:Public" }, 200], [[{ type: "Link", href: PUBLIC_IRI }], 200], - [[{ type: "Collection", id: `${actorIri}/followers` }], 404], + [[{ type: "Collection", id: `${ACTOR_IRI}/followers` }], 404], ] as const) { const create = await save( env, @@ -1036,7 +1039,7 @@ describe("embedded documents and implicit contexts", () => { type: "Note", content: "x", to: PUBLIC_IRI, - cc: { type: "Collection", id: `${actorIri}/followers` }, + cc: { type: "Collection", id: `${ACTOR_IRI}/followers` }, }, }, }, @@ -1070,7 +1073,7 @@ describe("embedded documents and implicit contexts", () => { "@context": AS, type: "Like", to: [PUBLIC_IRI], - audience: { type: "Link", href: bob }, + audience: { type: "Link", href: BOB }, object: "https://remote.example/notes/1", }, "activity", @@ -1101,7 +1104,7 @@ describe("embedded documents and implicit contexts", () => { assert.equal(status, 200); assert.deepEqual(body, publicDocument(saved.document)); } - const page = await get(harness.federation, `${actorIri}/outbox?cursor=`); + const page = await get(harness.federation, `${ACTOR_IRI}/outbox?cursor=`); assert.deepEqual(page.body.orderedItems, [ publicDocument(listed.document), publicDocument(linked.document), @@ -1145,13 +1148,13 @@ describe("embedded documents and implicit contexts", () => { 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", ); - const link = "https://remote.example/links/1"; // Two embedded documents with one id are one, which neither tells. const shared = await save( env, @@ -1159,8 +1162,8 @@ describe("embedded documents and implicit contexts", () => { "@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 }, + object: { type: "Link", id: LINK, href: "https://remote.example/n" }, + target: { type: "Link", id: LINK, href: note.iri }, }, "activity", ); @@ -1172,8 +1175,8 @@ describe("embedded documents and implicit contexts", () => { "@context": AS, type: "Add", to: [PUBLIC_IRI], - object: link, - target: { type: "Link", id: link, href: note.iri }, + object: LINK, + target: { type: "Link", id: LINK, href: note.iri }, }, "activity", ); @@ -1186,8 +1189,8 @@ describe("embedded documents and implicit contexts", () => { 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 self = "https://remote.example/x"; const saved = await save( env, { @@ -1195,7 +1198,7 @@ describe("embedded documents and implicit contexts", () => { type: "Like", to: [PUBLIC_IRI], object: "https://remote.example/notes/1", - "ex:quoted": { type: "Link", id: self, href: self }, + "ex:quoted": { type: "Link", id: SELF, href: SELF }, }, "activity", ); @@ -1268,7 +1271,7 @@ describe("embedded documents and implicit contexts", () => { object: { id: "https://remote.example/deletes/1", type: "Delete", - actor: bob, + actor: BOB, object: note.iri, }, }, @@ -1311,12 +1314,12 @@ describe("embedded documents and implicit contexts", () => { type: "Note", content: "open", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], attachment: { type: "Note", content: "deep", to: PUBLIC_IRI, - bto: blind, + bto: BLIND, }, }), "activity", @@ -1345,22 +1348,22 @@ describe("embedded documents and implicit contexts", () => { } const hidden: SavedResource[] = []; for (const quoted of [ - { type: "Note", content: "secret", to: [bob], bcc: [blind] }, + { type: "Note", content: "secret", to: [BOB], bcc: [BLIND] }, { type: "Note", content: "secret", - to: { id: `${actorIri}/followers` }, + 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: { "@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 }, + 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: BOB, [`${AS}#to`]: PUBLIC_IRI }, { type: "Note", content: "secret", @@ -1374,31 +1377,31 @@ describe("embedded documents and implicit contexts", () => { type: "Note", content: "x", to: [PUBLIC_IRI], - labels: [{ type: "Note", content: "secret", to: [bob] }], + 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] } }, + 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] }, + { 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"] }, + { type: "Note", content: "secret", to: [BOB, "_:Public"] }, // Only a Link names a recipient by its href. { type: "Note", content: "secret", - to: [bob], + 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" } }, + { "@value": { type: "Note", to: [BOB], bcc: [BLIND], content: "x" } }, { - id: bob, + id: BOB, "@reverse": { attributedTo: { type: "Note", content: "x" } }, }, // A value with keys a context of its own may alias is not read, @@ -1412,27 +1415,27 @@ describe("embedded documents and implicit contexts", () => { type: "Note", content: "x", to: [PUBLIC_IRI], - cc: { "@set": [], bcc: blind }, + cc: { "@set": [], bcc: BLIND }, }, // Properties nested with @nest are not read. { type: "Note", content: "secret", to: [PUBLIC_IRI], - "@nest": { cc: [bob] }, + "@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: "Note", content: "secret", to: BOB, toMap: { id: PUBLIC_IRI } }, { type: "Like", object: { type: "Link", - href: ["https://remote.example/notes/1", `${actorIri}/notes/1`], + href: ["https://remote.example/notes/1", `${ACTOR_IRI}/notes/1`], }, }, - { "@context": { secret: "as:bcc" }, content: "x", secret: [blind] }, + { "@context": { secret: "as:bcc" }, content: "x", secret: [BLIND] }, ]) { hidden.push(await save(env, like(quoted), "activity")); } @@ -1458,24 +1461,24 @@ describe("embedded documents and implicit contexts", () => { // So does an activity about it, unless that is its Delete. const liking = await save( env, - like({ type: "Like", actor: bob, object: note.iri }), + like({ type: "Like", actor: BOB, object: note.iri }), "activity", ); const deleting = await save( env, - like({ type: "Delete", actor: actorIri, object: note.iri }), + like({ type: "Delete", actor: ACTOR_IRI, object: note.iri }), "activity", ); const blankDeleting = await save( env, - like({ type: "_:Delete", actor: actorIri, object: note.iri }), + like({ type: "_:Delete", actor: ACTOR_IRI, object: note.iri }), "activity", ); const linking = await save( env, like({ type: "Like", - actor: bob, + actor: BOB, object: { type: "Link", id: "https://remote.example/links/1", @@ -1513,7 +1516,7 @@ describe("embedded documents and implicit contexts", () => { { type: "Like", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], object: "https://remote.example/notes/1", }, "activity", @@ -1542,7 +1545,7 @@ 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, { + const legacy = await createLegacyObject(env, PRINCIPAL, { actorId: localActorId, type: "Note", contentHtml: "

A

", @@ -1559,7 +1562,7 @@ describe("outboxes of saved and legacy activities", () => { "@context": AS, type: "Like", to: [PUBLIC_IRI], - bcc: [blind], + bcc: [BLIND], object: "https://remote.example/notes/1", }, "activity", @@ -1575,7 +1578,7 @@ describe("outboxes of saved and legacy activities", () => { { "@context": AS, type: "Like", - to: [`${actorIri}/followers`], + to: [`${ACTOR_IRI}/followers`], object: "https://remote.example/notes/2", }, "activity", @@ -1596,8 +1599,8 @@ describe("outboxes of saved and legacy activities", () => { env, noteIri.slice(noteIri.lastIndexOf("/") + 1) as Uuid, ); - const collection = await get(harness.federation, `${actorIri}/outbox`); - const page = await get(harness.federation, `${actorIri}/outbox?cursor=`); + 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( @@ -1605,7 +1608,7 @@ describe("outboxes of saved and legacy activities", () => { [ deletion.iri, like.iri, - `https://${host}/ap/creates/${legacy.activityId}`, + `https://${HOST}/ap/creates/${legacy.activityId}`, ], ); assert.equal(collection.body.totalItems, items.length); @@ -1652,7 +1655,7 @@ describe("outboxes of saved and legacy activities", () => { ); const page = await get( harness.federation, - `${actorIri}/outbox?cursor=`, + `${ACTOR_IRI}/outbox?cursor=`, ); assert.equal(page.status, 200); // A JSON-LD client reads the page, not only its JSON. @@ -1693,7 +1696,7 @@ describe("outboxes of saved and legacy activities", () => { }, ]) { const id = uuidV7(); - const iri = `https://${host}/ap/activities/${id}`; + const iri = `https://${HOST}/ap/activities/${id}`; await harness.db.transaction(async (tx) => { await ensureResource(tx, iri, id); await appendResourceRevision(tx, { @@ -1703,7 +1706,7 @@ describe("outboxes of saved and legacy activities", () => { kind: "snapshot", document: { id: iri, - actor: actorIri, + actor: ACTOR_IRI, to: [PUBLIC_IRI], ...document, }, @@ -1719,8 +1722,8 @@ describe("outboxes of saved and legacy activities", () => { for (const iri of broken) { assert.equal((await get(harness.federation, iri)).status, 404); } - const collection = await get(harness.federation, `${actorIri}/outbox`); - const page = await get(harness.federation, `${actorIri}/outbox?cursor=`); + 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( @@ -1751,7 +1754,7 @@ describe("outboxes of saved and legacy activities", () => { for (let index = 0; index < 120; index += 1) { await save( env, - like({ type: "Note", content: "secret", to: [bob] }), + like({ type: "Note", content: "secret", to: [BOB] }), "activity", ); } @@ -1766,13 +1769,16 @@ describe("outboxes of saved and legacy activities", () => { } const ids = (body: Record) => (body.orderedItems as Record[]).map(({ id }) => id); - const first = await get(harness.federation, `${actorIri}/outbox?cursor=`); + 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, `${actorIri}/outbox`); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); assert.equal(collection.body.totalItems, 21); }); }); @@ -1795,7 +1801,7 @@ describe("outboxes of saved and legacy activities", () => { .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, `${actorIri}/outbox`); + const collection = await get(harness.federation, `${ACTOR_IRI}/outbox`); assert.equal(collection.body.totalItems, 0); }); }); diff --git a/packages/federation/src/serving/document.ts b/packages/federation/src/serving/document.ts index 318e780e..f1f1d825 100644 --- a/packages/federation/src/serving/document.ts +++ b/packages/federation/src/serving/document.ts @@ -15,11 +15,11 @@ // along with this program. If not, see . import { + ADDRESSING_PROPERTIES, type DocumentNode, type ExtensionNode, type FieldPath, type JsonValue, - addressingProperties, describeDocument, } from "@drfed/models/document"; import { PUBLIC_IRI } from "@drfed/models/resource"; @@ -28,13 +28,15 @@ import { Object as APObject } from "@fedify/vocab"; import type { JsonObject } from "../local-write/types.ts"; -const activityStreams = "https://www.w3.org/ns/activitystreams"; +const ACTIVITY_STREAMS = "https://www.w3.org/ns/activitystreams"; /** The type IRI of `Create`, which has its own activity route. */ -export const createType = `${activityStreams}#Create`; +export const CREATE_TYPE = `${ACTIVITY_STREAMS}#Create`; /** The type IRI of `Delete`, which outlives the object it deletes. */ -export const deleteType = `${activityStreams}#Delete`; -const blindProperties = ["bto", "bcc"] as const; -const recipientProperties: ReadonlySet = new Set(addressingProperties); +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); @@ -53,7 +55,7 @@ const blindKeys = ( nodes.map((node) => [ pathKey(node.path), new Set( - blindProperties.flatMap( + BLIND_PROPERTIES.flatMap( (property) => node.properties.get(property) ?? [], ), ), @@ -88,7 +90,7 @@ const omitKeys = ( const statedContext = (document: JsonObject): JsonObject => "@context" in document ? document - : { "@context": activityStreams, ...document }; + : { "@context": ACTIVITY_STREAMS, ...document }; // An embedded document sits at [...parent, key] or [...parent, key, index]. const parentKey = (path: FieldPath): string | null => @@ -154,12 +156,12 @@ const recipients = ( children: readonly Embedded[], ): { readonly property: string; readonly iri: string | null }[] => [ ...node.identifiers - .filter(({ property }) => recipientProperties.has(property)) + .filter(({ property }) => RECIPIENT_PROPERTIES.has(property)) .map(({ property, iri }) => ({ property, iri })), ...children .filter( (child) => - child.property != null && recipientProperties.has(child.property), + child.property != null && RECIPIENT_PROPERTIES.has(child.property), ) .map((child) => ({ property: child.property!, iri: child.iri })), ]; @@ -305,9 +307,9 @@ export function inspectDocument( // A node object in an extension value has no kind the validator reads. objectReferences: [ ...nodes.filter( - ({ kind, type }) => kind === "activity" && type !== deleteType, + ({ kind, type }) => kind === "activity" && type !== DELETE_TYPE, ), - ...extensions.filter(({ types }) => !types.includes(deleteType)), + ...extensions.filter(({ types }) => !types.includes(DELETE_TYPE)), ].flatMap((node) => referredObjects(node, children(node), links)), restricted: [...embedded, ...extensions].some((node) => isRestricted(node, children(node)), diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index e2a621dd..b972ce39 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -40,9 +40,9 @@ import { servedObjectRevision, } from "../visibility.ts"; import { + CREATE_TYPE, type DocumentInspection, type ServedDocument, - createType, inspectDocument, } from "./document.ts"; import { documentActivity, documentObject } from "./vocab.ts"; @@ -73,11 +73,13 @@ interface RevisionRow { // Lookups of deleted objects stay well below the 65,535 parameters // PostgreSQL binds in one statement. -const lookupSize = 1000; +const LOOKUP_SIZE = 1000; // Outbox pages read past hidden activities in batches that grow up to this. -const maximumBatchSize = 1000; +const MAXIMUM_BATCH_SIZE = 1000; // The outbox counter reads the documents of this many activities at a time. -const countBatchSize = 100; +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. @@ -89,15 +91,14 @@ const chunks = (values: readonly T[], size: number): T[][] => ); // The revisions logged as unservable, so that one is logged once however -// often pages and counters read it. Past its limit, the revision logged -// longest ago is forgotten. +// often pages and counters read it. Past LOGGED_REVISION_LIMIT, the +// revision logged longest ago is forgotten. const loggedRevisions = new Set(); -const loggedRevisionLimit = 4096; function logOnce(row: RevisionRow, log: () => void): void { const key = row.revisionId ?? row.iri; if (loggedRevisions.has(key)) return; - if (loggedRevisions.size >= loggedRevisionLimit) { + if (loggedRevisions.size >= LOGGED_REVISION_LIMIT) { loggedRevisions.delete(loggedRevisions.values().next().value!); } loggedRevisions.add(key); @@ -175,7 +176,7 @@ async function deletedObjects( iris: readonly string[], ): Promise> { const rows = await Promise.all( - chunks(iris, lookupSize).map((chunk) => + chunks(iris, LOOKUP_SIZE).map((chunk) => db .select({ iri: resources.iri }) .from(resources) @@ -260,7 +261,10 @@ export async function findServedActivity( return activity == null ? null : toCreate(ctx, activity); } const [served] = await servedRevisions(db, [row]); - if (served == null || (served.type === createType) !== (route === "create")) { + if ( + served == null || + (served.type === CREATE_TYPE) !== (route === "create") + ) { return null; } return documentActivity(served); @@ -458,7 +462,9 @@ export async function loadOutboxPage( } if (batch.length < size) break; cursor = batch.at(-1)!.position; - if (size < maximumBatchSize) size = Math.min(size * 2, maximumBatchSize); + if (size < MAXIMUM_BATCH_SIZE) { + size = Math.min(size * 2, MAXIMUM_BATCH_SIZE); + } } const page = items.slice(0, limit); return { @@ -514,11 +520,11 @@ export async function countOutbox( ), ) .orderBy(asc(collectionItems.itemId)) - .limit(countBatchSize); + .limit(COUNT_BATCH_SIZE); // oxlint-disable-next-line no-await-in-loop const served = await servedRevisions(db, saved); count += served.filter((document) => document != null).length; - if (saved.length < countBatchSize) return count; + if (saved.length < COUNT_BATCH_SIZE) return count; after = saved.at(-1)!.id; } } diff --git a/packages/federation/src/serving/vocab.ts b/packages/federation/src/serving/vocab.ts index efd47642..68c0692a 100644 --- a/packages/federation/src/serving/vocab.ts +++ b/packages/federation/src/serving/vocab.ts @@ -19,7 +19,7 @@ import { Object as APObject, Activity, Create } from "@fedify/vocab"; import type { JsonObject } from "../local-write/types.ts"; -import { type ServedDocument, createType } from "./document.ts"; +import { CREATE_TYPE, type ServedDocument } from "./document.ts"; /** The options of Fedify's `toJsonLd()`. */ type JsonLdOptions = NonNullable[0]>; @@ -98,7 +98,7 @@ function servedId(served: ServedDocument): URL { * @returns The vocabulary object. */ export function documentActivity(served: ServedDocument): Activity { - return served.type === createType + return served.type === CREATE_TYPE ? new DocumentCreate(servedId(served), served.document) : new DocumentActivity(servedId(served), served.document); } diff --git a/packages/models/README.md b/packages/models/README.md index e7db9f98..cab9ed51 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`, diff --git a/packages/models/src/document.test.ts b/packages/models/src/document.test.ts index 6f7080eb..28434066 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], { @@ -4260,7 +4260,7 @@ describe("describeDocument() semantic view", () => { it("describes node objects in extension values with the context they inherit", () => { const nodes = describeDocument({ "@context": [ - context, + CONTEXT, { ex: "https://ext.example/ns#", hidden: { "@id": "as:bcc", "@type": "@id" }, @@ -4268,7 +4268,7 @@ describe("describeDocument() semantic view", () => { ], type: "Like", actor: "https://example.com/alice", - object: like.object, + object: LIKE.object, "ex:quoted": { type: "Note", to: ["https://example.com/bob", { id: "as:Public" }], @@ -4320,7 +4320,7 @@ describe("describeDocument() semantic view", () => { property: "id", path: ["ex:quoted", "to", 1, "id"], value: "as:Public", - iri: `${context}#Public`, + iri: `${CONTEXT}#Public`, }, ]); assert.deepEqual(Object.fromEntries(extensions[3]!.properties), {}); @@ -4329,7 +4329,7 @@ describe("describeDocument() semantic view", () => { it("reads extension node objects through containers, sets and keyword aliases", () => { const nodes = describeDocument({ "@context": [ - context, + CONTEXT, { ex: "https://ext.example/ns#", listed: { "@id": "as:bcc", "@type": "@id", "@container": "@list" }, @@ -4337,7 +4337,7 @@ describe("describeDocument() semantic view", () => { ], type: "Like", actor: "https://example.com/alice", - object: like.object, + object: LIKE.object, "ex:quoted": { type: "Delete", listed: ["https://example.com/blind"], @@ -4354,7 +4354,7 @@ describe("describeDocument() semantic view", () => { 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(quoted!.types, [`${CONTEXT}#Delete`]); assert.deepEqual(Object.fromEntries(quoted!.properties), { bcc: ["listed"], to: ["to"], @@ -4378,15 +4378,15 @@ describe("describeDocument() semantic view", () => { const quotedNode = (quoted: unknown) => describeDocument({ - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { ex: "https://ext.example/ns#", hint: "as:to", toMap: { "@id": "as:to", "@container": "@language" }, labels: { "@id": "ex:labels", "@container": "@language" }, - _: `${context}#`, + _: `${CONTEXT}#`, }, ], "ex:quoted": quoted, @@ -4395,7 +4395,7 @@ describe("describeDocument() semantic view", () => { 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", [`${CONTEXT}#to`]: "as:Public" }, { type: "Note", id: "/notes/1" }, { type: "Note", @@ -4422,12 +4422,12 @@ describe("describeDocument() semantic view", () => { } // 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#" }], + ...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: LIKE.object, type: "Note" }, { id: "https://a.example/apart", type: "Note" }, // Its own properties name no other node, but those it holds do. { @@ -4458,8 +4458,8 @@ describe("describeDocument() semantic view", () => { // 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#" }], + ...LIKE, + "@context": [CONTEXT, { ex: "https://ext.example/ns#" }], "ex:quoted": { "@context": { kind: "@type" }, kind: "@json", @@ -4487,13 +4487,13 @@ describe("describeDocument() semantic view", () => { assert.equal(blank?.uninterpreted, false); assert.deepEqual( blank?.identifiers.map(({ iri }) => iri), - ["_:b0", `${context}#Public`], + ["_:b0", `${CONTEXT}#Public`], ); assert.deepEqual(quotedNode({ type: "_:Delete" })?.types, ["_:Delete"]); assert.equal( describeDocument({ - ...like, - "@context": [context, { _: `${context}#` }], + ...LIKE, + "@context": [CONTEXT, { _: `${CONTEXT}#` }], type: "_:Like", }), null, @@ -4502,9 +4502,9 @@ describe("describeDocument() semantic view", () => { const labelled = quotedNode({ type: "Note", labels: { id: "Halo" } }); assert.equal(labelled?.uninterpreted, false); const labels = describeDocument({ - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { labels: { "@id": "https://example.com/labels", @@ -4522,9 +4522,9 @@ describe("describeDocument() semantic view", () => { }); assert.equal(listedLabels?.uninterpreted, false); const listedNode = describeDocument({ - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { labels: { "@id": "https://example.com/labels", @@ -4544,9 +4544,9 @@ describe("describeDocument() semantic view", () => { ); // An object in a language map is no valid JSON-LD, and is not read. const labelled2 = describeDocument({ - ...like, + ...LIKE, "@context": [ - context, + CONTEXT, { labels: { "@id": "https://example.com/labels", @@ -4568,17 +4568,17 @@ describe("describeDocument() semantic view", () => { ); // A JSON literal holds data, however its keywords are spelled. const literal = describeDocument({ - ...like, + ...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 90edf36a..7d37fc1e 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 }); }; @@ -840,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; }; @@ -855,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, @@ -875,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) { @@ -1031,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" @@ -1075,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); } @@ -1115,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]; @@ -1420,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"; @@ -1582,13 +1623,6 @@ function checkLinkProperty( } } -// Objects with these keywords are values or lists, not JSON-LD node objects. -const valueObjectKeywords: ReadonlySet = new Set([ - "@value", - "@list", - "@set", -]); - // 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( @@ -1605,7 +1639,7 @@ function isNodeObject( context: DocumentContext | null, ): boolean { return !Object.keys(values).some((key) => - valueObjectKeywords.has(keywordOf(key, context) ?? ""), + VALUE_OBJECT_KEYWORDS.has(keywordOf(key, context) ?? ""), ); } @@ -1752,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; } @@ -1915,7 +1951,7 @@ function checkDocumentValue( referenceProperties.push( "attributedTo", "location", - ...addressingProperties, + ...ADDRESSING_PROPERTIES, ); } if (isActivity) { @@ -2645,34 +2681,6 @@ 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 { @@ -2694,7 +2702,7 @@ 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"; } @@ -2714,7 +2722,7 @@ function describeProperties( const sources = properties.get(name) ?? []; sources.push(source); properties.set(name, sources); - if (!identifierProperties.has(name)) continue; + 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]]) @@ -2757,9 +2765,6 @@ const isReadableReference = (value: string): boolean => value.startsWith("_:") || (/^[a-z][a-z\d+.-]*:/iu.test(value) && URL.canParse(value)); -// A node object has one of each. -const functionalReferences = ["id", "href"] as const; - function typeKind(iri: string): DocumentNodeKind | undefined { const Type = iri === Link.typeId.href @@ -2814,7 +2819,7 @@ function readExtensionEntry( reading: ExtensionReading, ): boolean { const { name, compatible } = property; - const reference = identifierProperties.has(name); + const reference = IDENTIFIER_PROPERTIES.has(name); if (typeof entry === "string") { if (name === "type") { reading.types.push( @@ -2844,7 +2849,7 @@ function readExtensionEntry( // Tell whether an id or href has more than one value, or a node object. function hasAmbiguousReference(reading: ExtensionReading): boolean { - return functionalReferences.some( + return FUNCTIONAL_REFERENCES.some( (name) => reading.identifiers.filter(({ property }) => property === name).length > 1 || reading.children.some(([, property]) => property === name), @@ -2894,7 +2899,7 @@ function describeExtensionNode( context.terms.get(source)?.container === "@language" && isLanguageMap(values[source]) ) { - if (identifierProperties.has(name) && values[source] != null) { + if (IDENTIFIER_PROPERTIES.has(name) && values[source] != null) { readable = false; } continue; From 1f576bdba95f1ecced73d5a528af0ecf639eb2a4 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 9 Nov 2026 00:03:52 +0900 Subject: [PATCH 25/25] Name serving helpers for what they do The private helpers of serving/resource.ts that read the database get verb names: deletedObjects() becomes findDeletedObjects() and servedRevisions() becomes serveRevisions(). isServable() becomes canServe(). Their documentation now says what they do. inspectRevision() returns null only for a document that is not a JSON object the validator reads or whose id is missing or not the resource's; an uninterpreted document is inspected and canServe() refuses it. canServe() decides only the rules a saved document tells, while callers apply the actor and resource rules when they select rows and the route on the served document's type. ActivitySnapshot explains why it keeps blind recipients and that the sender of #145 will dispatch it. The user approved the plans for these review comments and had Claude Code apply them. Claude Code (Fable) found that the first wording misplaced the route rule, left out two cases where inspectRevision() returns null, and described the unwritten sender as current; Codex (Sol) found the last as well, and Codex (Astra, Sol) confirmed all three, which this commit fixes. The build, mise run check and mise run test passed. https://github.com/fedify-dev/drfed/pull/177#discussion_r4505970168 https://github.com/fedify-dev/drfed/pull/177#discussion_r4506159085 https://github.com/fedify-dev/drfed/pull/177#discussion_r4506083921 https://github.com/fedify-dev/drfed/pull/177#pullrequestreview-5817618794 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Claude Code:claude-fable-5-1 Assisted-by: Codex:gpt-6-astra Assisted-by: Codex:gpt-6-sol --- packages/federation/src/serving/resource.ts | 45 ++++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/federation/src/serving/resource.ts b/packages/federation/src/serving/resource.ts index b972ce39..ba8cffac 100644 --- a/packages/federation/src/serving/resource.ts +++ b/packages/federation/src/serving/resource.ts @@ -111,12 +111,16 @@ function isJsonObject(value: unknown): value is JsonObject { /** * Reads a saved revision's document for serving. 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 and + * 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, and is not served either. Each revision is logged once. - * @returns The inspection, or `null` when the document cannot be served. + * 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) @@ -152,9 +156,12 @@ function inspectRevision(row: RevisionRow): DocumentInspection | null { * 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 the document is served. + * @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 isServable( +function canServe( inspection: DocumentInspection, deleted: ReadonlySet, ): boolean { @@ -171,7 +178,7 @@ function isServable( * Finds the deleted local objects among IRIs, in lookups of a bounded size. * @returns The IRIs of those objects. */ -async function deletedObjects( +async function findDeletedObjects( db: Database, iris: readonly string[], ): Promise> { @@ -194,12 +201,12 @@ async function deletedObjects( * @returns For each revision, its served form, or `null` when it is not * served. */ -async function servedRevisions( +async function serveRevisions( db: Database, rows: readonly RevisionRow[], ): Promise<(ServedDocument | null)[]> { const inspections = rows.map(inspectRevision); - const deleted = await deletedObjects(db, [ + const deleted = await findDeletedObjects(db, [ ...new Set( inspections.flatMap((inspection) => inspection == null @@ -209,7 +216,7 @@ async function servedRevisions( ), ]); return inspections.map((inspection) => - inspection != null && isServable(inspection, deleted) + inspection != null && canServe(inspection, deleted) ? inspection.served : null, ); @@ -260,7 +267,7 @@ export async function findServedActivity( }); return activity == null ? null : toCreate(ctx, activity); } - const [served] = await servedRevisions(db, [row]); + const [served] = await serveRevisions(db, [row]); if ( served == null || (served.type === CREATE_TYPE) !== (route === "create") @@ -335,7 +342,7 @@ export async function findServedObject( return new Tombstone({ id: new URL(row.iri), deleted: row.deleted }); } if (row.kind !== "snapshot") return null; - const [served] = await servedRevisions(db, [row]); + const [served] = await serveRevisions(db, [row]); return served == null ? null : documentObject(served); } @@ -407,7 +414,7 @@ async function loadOutboxBatch( const byId = new Map(typed.map((activity) => [activity.id, activity])); const saved = rows.filter((row) => !row.typed); const served = new Map( - (await servedRevisions(db, saved)).map((document, index) => [ + (await serveRevisions(db, saved)).map((document, index) => [ saved[index]!.id, document, ]), @@ -522,15 +529,23 @@ export async function countOutbox( .orderBy(asc(collectionItems.itemId)) .limit(COUNT_BATCH_SIZE); // oxlint-disable-next-line no-await-in-loop - const served = await servedRevisions(db, saved); + 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 document, as #145 dispatches it. */ +/** + * 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;