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: "
",
+ 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: "