From 5d9dd09f388dbd3bc2418568ce3dcc17a4deffe8 Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 04:21:59 +0900
Subject: [PATCH 01/10] Add custom collections cookbook
Add an in-repository custom collections example that keeps the
bookmark domain small and focuses on dispatcher patterns. The example
shows cursor-based pagination, URI template filtering, actor collection
links, and requester-aware followers-only collections.
Link the manual's custom collections section to the runnable example
and add a changelog entry for the accepted issue.
https://github.com/fedify-dev/fedify/issues/694
Assisted-by: Codex:gpt-5.5
---
CHANGES.md | 7 +
docs/manual/collections.md | 5 +
examples/custom-collections/README.md | 23 +-
examples/custom-collections/main.ts | 446 ++++++++++++++++++++------
4 files changed, 376 insertions(+), 105 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 8f4655411..51f2b372d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -225,16 +225,23 @@ To be released.
redistribution that threadiverse software (Lemmy, Mbin, NodeBB) uses to fan
activity out to every subscriber. [[#704], [#710]]
+ - Added a custom collections cookbook example for bookmark-like data,
+ demonstrating cursor pagination, URI-template filtering, collection
+ counters, actor collection links, and requester-aware collections using
+ `ctx.getSignedKeyOwner()`. [[#694], [#722]]
+
[*Building a federated blog* tutorial]: https://fedify.dev/tutorial/astro-blog
[Astro]: https://astro.build/
[Bun]: https://bun.sh/
[*Building a threadiverse community platform*]: https://fedify.dev/tutorial/threadiverse
[*Creating your own federated microblog*]: https://fedify.dev/tutorial/microblog
[#691]: https://github.com/fedify-dev/fedify/issues/691
+[#694]: https://github.com/fedify-dev/fedify/issues/694
[#695]: https://github.com/fedify-dev/fedify/pull/695
[#704]: https://github.com/fedify-dev/fedify/issues/704
[#706]: https://github.com/fedify-dev/fedify/issues/706
[#715]: https://github.com/fedify-dev/fedify/pull/715
+[#722]: https://github.com/fedify-dev/fedify/pull/722
Version 2.1.10
diff --git a/docs/manual/collections.md b/docs/manual/collections.md
index abad8d0e1..7ea5bda90 100644
--- a/docs/manual/collections.md
+++ b/docs/manual/collections.md
@@ -1396,12 +1396,17 @@ followers, Fedify allows you to create custom collections for your specific
needs. Custom collections can be used to expose any type of ActivityPub
objects in a paginated manner.
+For runnable code that compares several custom collection patterns, see the
+[custom collections example].
+
There are two types of custom collections you can create:
- **Collection**: An unordered collection of objects
- **Ordered Collection**: An ordered collection of objects where the order
matters
+[custom collections example]: https://github.com/fedify-dev/fedify/tree/main/examples/custom-collections
+
### Setting up a custom collection
To create a custom collection, you use either `setCollectionDispatcher()` for
diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md
index d816b90b9..a0f2c507f 100644
--- a/examples/custom-collections/README.md
+++ b/examples/custom-collections/README.md
@@ -1,11 +1,28 @@
Custom collections example
==========================
-This example demonstrates how to implement custom collections in Fedify.
-Custom collections allow you to define your own ActivityPub collections with
-custom logic for dispatching items and counting collection sizes.
+This example is a small cookbook for custom ActivityPub collections in Fedify.
+It uses a single-user bookmark log as the domain, but the important part is
+how each collection dispatcher maps server-side data to an ActivityPub
+collection.
+
+The script demonstrates three patterns:
+
+ - `/users/alice/collections/public`: a public `OrderedCollection` with
+ cursor-based pages, `setCounter()`, `setFirstCursor()`, and
+ `setLastCursor()`.
+ - `/users/alice/collections/tags/{tag}`: a parameterized collection that
+ filters bookmarks using a URI template value.
+ - `/users/alice/collections/followers-only`: a collection whose result
+ depends on the signed requester. It calls `ctx.getSignedKeyOwner()` and
+ returns an empty collection to unsigned or non-follower requests.
+
+Run it from this directory:
~~~~ sh
deno task codegen # At very first time only
deno run -A ./main.ts
~~~~
+
+The output prints the actor document, collection metadata responses, and page
+responses for the example routes.
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index 55cda8f81..bb95862f5 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -1,118 +1,360 @@
-import { createFederation, MemoryKvStore } from "@fedify/fedify";
-import { Note } from "@fedify/vocab";
-
-// Mock data - in a real application, this would query your database
-const POSTS = [
- new Note({
- id: new URL("https://example.com/posts/post-1"),
- content: "ActivityPub is a decentralized social networking protocol...",
- tags: [
- new URL("https://example.com/tags/ActivityPub"),
- new URL("https://example.com/tags/Decentralization"),
- ],
- }),
-
- new Note({
- id: new URL("https://example.com/posts/post-2"),
- content: "Fedify makes it easy to build federated applications...",
- }),
-
- new Note({
- id: new URL("https://example.com/posts/post-3"),
- content: "WebFinger is a protocol for discovering information...",
- tags: [new URL("https://example.com/tags/ActivityPub")],
- }),
-
- new Note({
- id: new URL("https://example.com/posts/post-4"),
- content: "HTTP Signatures provide authentication for ActivityPub...",
- }),
-
- new Note({
- id: new URL("https://example.com/posts/post-5"),
- content: "Understanding ActivityPub's data model is crucial...",
- }),
-];
+import {
+ createFederation,
+ MemoryKvStore,
+ type RequestContext,
+} from "@fedify/fedify";
+import {
+ Article,
+ Hashtag,
+ Link,
+ Person,
+ PUBLIC_COLLECTION,
+} from "@fedify/vocab";
+
+const OWNER = "alice";
+const PAGE_SIZE = 2;
-function getTagFromUrl(url: string): string {
- const parts = url.split("/");
- return parts[parts.length - 1];
+const PUBLIC_BOOKMARKS = "public-bookmarks";
+const TAGGED_BOOKMARKS = "tagged-bookmarks";
+const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks";
+
+interface Bookmark {
+ id: string;
+ title: string;
+ href: string;
+ note: string;
+ tags: string[];
+ visibility: "public" | "followers";
+ savedAt: Temporal.Instant;
}
-function getTaggedPostsByTag(tag: string): Note[] {
- return POSTS
- .filter((post) => {
- if (!post.tagIds) {
- return false;
- }
- return post.tagIds.some((tagId) => {
- return getTagFromUrl(tagId.toString()) === tag;
- });
+const bookmarks: Bookmark[] = [
+ {
+ id: "fedify-manual",
+ title: "Fedify manual",
+ href: "https://fedify.dev/manual/",
+ note: "Reference material for building ActivityPub servers with Fedify.",
+ tags: ["fedify", "activitypub"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-20T09:00:00Z"),
+ },
+ {
+ id: "activitypub-spec",
+ title: "ActivityPub specification",
+ href: "https://www.w3.org/TR/activitypub/",
+ note: "The W3C ActivityPub recommendation.",
+ tags: ["activitypub", "spec"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-19T12:00:00Z"),
+ },
+ {
+ id: "uri-template",
+ title: "URI Template",
+ href: "https://www.rfc-editor.org/rfc/rfc6570",
+ note: "How Fedify dispatcher path parameters are expanded.",
+ tags: ["spec", "routing"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-18T16:30:00Z"),
+ },
+ {
+ id: "private-reading-list",
+ title: "Private reading list",
+ href: "https://example.net/reading-list",
+ note: "A bookmark visible only to accepted followers.",
+ tags: ["fedify", "reading"],
+ visibility: "followers",
+ savedAt: Temporal.Instant.from("2026-04-17T08:15:00Z"),
+ },
+ {
+ id: "moderation-notes",
+ title: "Moderation notes",
+ href: "https://example.net/moderation",
+ note: "Follower-facing notes for a small community server.",
+ tags: ["activitypub", "moderation"],
+ visibility: "followers",
+ savedAt: Temporal.Instant.from("2026-04-16T10:45:00Z"),
+ },
+];
+
+export const followerIds = new Set([
+ "https://remote.example/users/bob",
+ "https://social.example/users/carol",
+]);
+
+export const federation = createFederation({
+ kv: new MemoryKvStore(),
+});
+
+federation
+ .setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
+ if (identifier !== OWNER) return null;
+
+ return new Person({
+ id: ctx.getActorUri(identifier),
+ preferredUsername: identifier,
+ name: "Alice's bookmarks",
+ summary: "A single-user bookmark log with custom collection examples.",
+ url: new URL(`/users/${identifier}`, ctx.url),
+ attachments: [
+ collectionLink(
+ ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
+ "Public bookmarks",
+ ),
+ collectionLink(
+ ctx.getCollectionUri(TAGGED_BOOKMARKS, {
+ identifier,
+ tag: "activitypub",
+ }),
+ "ActivityPub bookmarks",
+ ),
+ collectionLink(
+ ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
+ "Followers-only bookmarks",
+ ),
+ ],
});
-}
+ });
-async function demonstrateCustomCollection(): Promise {
- // Federation instance created for demonstration
- const federation = createFederation({ kv: new MemoryKvStore() });
-
- federation.setCollectionDispatcher(
- "TaggedPosts",
- Note,
- "/users/{userId}/tags/{tag}",
- (
- _ctx: { url: URL },
- values: Record,
- cursor: string | null,
- ) => {
- if (!values.tag) {
- throw new Error("Missing userId or tag in values");
- }
+federation
+ .setOrderedCollectionDispatcher(
+ PUBLIC_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/public",
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+ if (cursor == null) return null;
+
+ return pageBookmarks(
+ ctx,
+ publicBookmarks(),
+ cursor,
+ );
+ },
+ )
+ .setCounter((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return publicBookmarks().length;
+ })
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return firstCursor(publicBookmarks());
+ })
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return lastCursor(publicBookmarks());
+ });
+
+federation
+ .setOrderedCollectionDispatcher(
+ TAGGED_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/tags/{tag}",
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+ if (cursor == null) return null;
- // Normally here you would look up posts from a database by user ID and tag name:
- const posts = getTaggedPostsByTag(values.tag);
-
- if (cursor != null) {
- const idx = Number.parseInt(cursor, 10);
- if (Number.isNaN(idx) || idx > posts.length || idx < 0) {
- return { items: [], nextCursor: null, prevCursor: null };
- }
- return {
- items: idx < posts.length ? [posts[idx]] : [],
- nextCursor: idx < posts.length - 1 ? (idx + 1).toString() : null,
- prevCursor: idx > 0 ? (idx - 1).toString() : null,
- };
+ return pageBookmarks(
+ ctx,
+ taggedBookmarks(values.tag),
+ cursor,
+ );
+ },
+ )
+ .setCounter((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return taggedBookmarks(values.tag).length;
+ })
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return firstCursor(taggedBookmarks(values.tag));
+ })
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return lastCursor(taggedBookmarks(values.tag));
+ });
+
+federation
+ .setOrderedCollectionDispatcher(
+ FOLLOWERS_ONLY_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/followers-only",
+ async (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+ if (!await isFollowerRequest(ctx)) {
+ return { items: [], nextCursor: null, prevCursor: null };
}
- return { items: posts, nextCursor: null, prevCursor: null };
+ if (cursor == null) return null;
+
+ return pageBookmarks(
+ ctx,
+ followersOnlyBookmarks(),
+ cursor,
+ );
},
- ).setCounter((_ctx, values) => {
- // Return the total count of tagged posts
- const count = getTaggedPostsByTag(values.tag).length;
- return count;
+ )
+ .setCounter(async (ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return await isFollowerRequest(ctx) ? followersOnlyBookmarks().length : 0;
+ })
+ .setFirstCursor(async (ctx, values) => {
+ if (values.identifier !== OWNER || !await isFollowerRequest(ctx)) {
+ return null;
+ }
+ return firstCursor(followersOnlyBookmarks());
+ })
+ .setLastCursor(async (ctx, values) => {
+ if (values.identifier !== OWNER || !await isFollowerRequest(ctx)) {
+ return null;
+ }
+ return lastCursor(followersOnlyBookmarks());
});
- return await federation.fetch(
- new Request(
- "https://example.com/users/123/tags/ActivityPub",
- {
- headers: {
- Accept: "application/activity+json",
- },
- },
+function collectionLink(href: URL, name: string): Link {
+ return new Link({
+ href,
+ rel: "collection",
+ name,
+ });
+}
+
+function publicBookmarks(): Bookmark[] {
+ return sortBookmarks(
+ bookmarks.filter((bookmark) => bookmark.visibility === "public"),
+ );
+}
+
+function followersOnlyBookmarks(): Bookmark[] {
+ return sortBookmarks(
+ bookmarks.filter((bookmark) => bookmark.visibility === "followers"),
+ );
+}
+
+function taggedBookmarks(tag: string): Bookmark[] {
+ const normalizedTag = normalizeTag(tag);
+ return sortBookmarks(
+ bookmarks.filter((bookmark) =>
+ bookmark.tags.some((itemTag) => normalizeTag(itemTag) === normalizedTag)
),
- {
- contextData: undefined,
- },
);
}
-if (import.meta.main) {
- const response = await demonstrateCustomCollection();
-
- if (response.ok) {
- const jsonResponse = await response.json();
- console.log("Custom collection data:", jsonResponse);
- } else {
- const errorText = await response.text();
- console.log("Error response:", errorText);
+function sortBookmarks(items: Bookmark[]): Bookmark[] {
+ return items.toSorted((a, b) =>
+ Temporal.Instant.compare(b.savedAt, a.savedAt)
+ );
+}
+
+function pageBookmarks(
+ ctx: RequestContext,
+ items: Bookmark[],
+ cursor: string,
+): {
+ items: Article[];
+ nextCursor: string | null;
+ prevCursor: string | null;
+} {
+ const offset = parseCursor(cursor);
+ return {
+ items: items
+ .slice(offset, offset + PAGE_SIZE)
+ .map((bookmark) => toArticle(ctx, bookmark)),
+ nextCursor: offset + PAGE_SIZE < items.length
+ ? String(offset + PAGE_SIZE)
+ : null,
+ prevCursor: offset > 0 ? String(Math.max(0, offset - PAGE_SIZE)) : null,
+ };
+}
+
+function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
+ return new Article({
+ id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
+ attribution: ctx.getActorUri(OWNER),
+ name: bookmark.title,
+ summary: bookmark.note,
+ content: `${bookmark.title}
`,
+ url: new URL(bookmark.href),
+ published: bookmark.savedAt,
+ to: bookmark.visibility === "public" ? PUBLIC_COLLECTION : undefined,
+ tags: bookmark.tags.map((tag) =>
+ new Hashtag({
+ href: new URL(
+ `/tags/${encodeURIComponent(normalizeTag(tag))}`,
+ ctx.url,
+ ),
+ name: `#${tag}`,
+ })
+ ),
+ });
+}
+
+async function isFollowerRequest(ctx: RequestContext): Promise {
+ const signedKeyOwner = await ctx.getSignedKeyOwner();
+ return signedKeyOwner?.id == null
+ ? false
+ : followerIds.has(signedKeyOwner.id.href);
+}
+
+function firstCursor(items: Bookmark[]): string | null {
+ return items.length < 1 ? null : "0";
+}
+
+function lastCursor(items: Bookmark[]): string | null {
+ if (items.length < 1) return null;
+ return String(Math.floor((items.length - 1) / PAGE_SIZE) * PAGE_SIZE);
+}
+
+function parseCursor(cursor: string): number {
+ const offset = Number.parseInt(cursor, 10);
+ return Number.isInteger(offset) && offset >= 0 ? offset : 0;
+}
+
+function normalizeTag(tag: string): string {
+ return tag.trim().toLowerCase();
+}
+
+async function fetchActivityJson(path: string): Promise {
+ const response = await federation.fetch(
+ new Request(new URL(path, "https://example.com"), {
+ headers: { Accept: "application/activity+json" },
+ }),
+ { contextData: undefined },
+ );
+
+ if (!response.ok) {
+ throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
+ return await response.json();
+}
+
+async function printActivityJson(label: string, path: string): Promise {
+ console.log(`\n## ${label}`);
+ console.log(JSON.stringify(await fetchActivityJson(path), null, 2));
+}
+
+if (import.meta.main) {
+ await printActivityJson("Actor with custom collection links", "/users/alice");
+ await printActivityJson(
+ "Public bookmarks collection",
+ "/users/alice/collections/public",
+ );
+ await printActivityJson(
+ "Public bookmarks first page",
+ "/users/alice/collections/public?cursor=0",
+ );
+ await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks collection",
+ "/users/alice/collections/tags/activitypub",
+ );
+ await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks first page",
+ "/users/alice/collections/tags/activitypub?cursor=0",
+ );
+ await printActivityJson(
+ "Followers-only collection requested without a signature",
+ "/users/alice/collections/followers-only",
+ );
+ await printActivityJson(
+ "Followers-only first page requested without a signature",
+ "/users/alice/collections/followers-only?cursor=0",
+ );
}
From 10d1fdffa6ca07fa4b590a346f16bb123c952d4f Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 04:39:06 +0900
Subject: [PATCH 02/10] Tighten custom collections example
Keep tag-filtered collections limited to public bookmarks so the public
route does not expose followers-only bookmark existence. Return an empty
item set for cursorless empty collections so valid empty custom collections
serialize instead of becoming 404 responses.
Escape bookmark fields before embedding them in Article content HTML so
the cookbook does not model unsafe rendering for user-supplied bookmark
data.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139888128
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139894765
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139894796
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139894811
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139894838
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139895143
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139895151
Assisted-by: Codex:gpt-5
---
examples/custom-collections/README.md | 2 +-
examples/custom-collections/main.ts | 46 +++++++++++++++++++++------
2 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md
index a0f2c507f..4b6a35d0f 100644
--- a/examples/custom-collections/README.md
+++ b/examples/custom-collections/README.md
@@ -12,7 +12,7 @@ The script demonstrates three patterns:
cursor-based pages, `setCounter()`, `setFirstCursor()`, and
`setLastCursor()`.
- `/users/alice/collections/tags/{tag}`: a parameterized collection that
- filters bookmarks using a URI template value.
+ filters public bookmarks using a URI template value.
- `/users/alice/collections/followers-only`: a collection whose result
depends on the signed requester. It calls `ctx.getSignedKeyOwner()` and
returns an empty collection to unsigned or non-follower requests.
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index bb95862f5..6c7215594 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -122,9 +122,8 @@ federation
"/users/{identifier}/collections/public",
(ctx, values, cursor) => {
if (values.identifier !== OWNER) return null;
- if (cursor == null) return null;
- return pageBookmarks(
+ return collectionBookmarks(
ctx,
publicBookmarks(),
cursor,
@@ -151,9 +150,8 @@ federation
"/users/{identifier}/collections/tags/{tag}",
(ctx, values, cursor) => {
if (values.identifier !== OWNER) return null;
- if (cursor == null) return null;
- return pageBookmarks(
+ return collectionBookmarks(
ctx,
taggedBookmarks(values.tag),
cursor,
@@ -183,9 +181,8 @@ federation
if (!await isFollowerRequest(ctx)) {
return { items: [], nextCursor: null, prevCursor: null };
}
- if (cursor == null) return null;
- return pageBookmarks(
+ return collectionBookmarks(
ctx,
followersOnlyBookmarks(),
cursor,
@@ -233,6 +230,7 @@ function taggedBookmarks(tag: string): Bookmark[] {
const normalizedTag = normalizeTag(tag);
return sortBookmarks(
bookmarks.filter((bookmark) =>
+ bookmark.visibility === "public" &&
bookmark.tags.some((itemTag) => normalizeTag(itemTag) === normalizedTag)
),
);
@@ -244,6 +242,24 @@ function sortBookmarks(items: Bookmark[]): Bookmark[] {
);
}
+function collectionBookmarks(
+ ctx: RequestContext,
+ items: Bookmark[],
+ cursor: string | null,
+): {
+ items: Article[];
+ nextCursor: string | null;
+ prevCursor: string | null;
+} {
+ return cursor == null
+ ? {
+ items: items.map((bookmark) => toArticle(ctx, bookmark)),
+ nextCursor: null,
+ prevCursor: null,
+ }
+ : pageBookmarks(ctx, items, cursor);
+}
+
function pageBookmarks(
ctx: RequestContext,
items: Bookmark[],
@@ -266,13 +282,16 @@ function pageBookmarks(
}
function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
+ const bookmarkUrl = new URL(bookmark.href);
return new Article({
id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
attribution: ctx.getActorUri(OWNER),
name: bookmark.title,
- summary: bookmark.note,
- content: `${bookmark.title}
`,
- url: new URL(bookmark.href),
+ summary: escapeHtml(bookmark.note),
+ content: `${
+ escapeHtml(bookmark.title)
+ }
`,
+ url: bookmarkUrl,
published: bookmark.savedAt,
to: bookmark.visibility === "public" ? PUBLIC_COLLECTION : undefined,
tags: bookmark.tags.map((tag) =>
@@ -287,6 +306,15 @@ function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
});
}
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
async function isFollowerRequest(ctx: RequestContext): Promise {
const signedKeyOwner = await ctx.getSignedKeyOwner();
return signedKeyOwner?.id == null
From 8d24c33b7f7b7e17528795da3818621868b87a41 Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 04:57:22 +0900
Subject: [PATCH 03/10] Polish custom collections cookbook
Advertise custom collections through actor streams, and use the
canonical followers collection URI for followers-only bookmark objects.
Move followers-only access control into the collection authorization
callback so the dispatcher, counter, and cursor callbacks stay focused
on collection data.
Reject invalid page cursors before deriving pagination links, and point
Hashtag links at the tag-filtered collection route demonstrated by the
example.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139954159
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139956536
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139963150
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139963180
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139972106
https://github.com/fedify-dev/fedify/pull/722#discussion_r3139972108
Assisted-by: Codex:gpt-5.5
---
CHANGES.md | 2 +-
examples/custom-collections/README.md | 4 +-
examples/custom-collections/main.ts | 123 +++++++++++++++-----------
3 files changed, 75 insertions(+), 54 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 51f2b372d..7d2fde52d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -227,7 +227,7 @@ To be released.
- Added a custom collections cookbook example for bookmark-like data,
demonstrating cursor pagination, URI-template filtering, collection
- counters, actor collection links, and requester-aware collections using
+ counters, actor stream links, and requester-aware collections using
`ctx.getSignedKeyOwner()`. [[#694], [#722]]
[*Building a federated blog* tutorial]: https://fedify.dev/tutorial/astro-blog
diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md
index 4b6a35d0f..db6df2186 100644
--- a/examples/custom-collections/README.md
+++ b/examples/custom-collections/README.md
@@ -14,8 +14,8 @@ The script demonstrates three patterns:
- `/users/alice/collections/tags/{tag}`: a parameterized collection that
filters public bookmarks using a URI template value.
- `/users/alice/collections/followers-only`: a collection whose result
- depends on the signed requester. It calls `ctx.getSignedKeyOwner()` and
- returns an empty collection to unsigned or non-follower requests.
+ depends on the signed requester. It uses `.authorize()` with
+ `ctx.getSignedKeyOwner()` so unsigned or non-follower requests are rejected.
Run it from this directory:
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index 6c7215594..3b9d19900 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -6,9 +6,9 @@ import {
import {
Article,
Hashtag,
- Link,
Person,
PUBLIC_COLLECTION,
+ type Recipient,
} from "@fedify/vocab";
const OWNER = "alice";
@@ -95,26 +95,34 @@ federation
name: "Alice's bookmarks",
summary: "A single-user bookmark log with custom collection examples.",
url: new URL(`/users/${identifier}`, ctx.url),
- attachments: [
- collectionLink(
- ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
- "Public bookmarks",
- ),
- collectionLink(
- ctx.getCollectionUri(TAGGED_BOOKMARKS, {
- identifier,
- tag: "activitypub",
- }),
- "ActivityPub bookmarks",
- ),
- collectionLink(
- ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
- "Followers-only bookmarks",
- ),
+ followers: ctx.getFollowersUri(identifier),
+ streams: [
+ ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
+ ctx.getCollectionUri(TAGGED_BOOKMARKS, {
+ identifier,
+ tag: "activitypub",
+ }),
+ ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
],
});
});
+federation.setFollowersDispatcher(
+ "/users/{identifier}/followers",
+ (_ctx, identifier) => {
+ if (identifier !== OWNER) return null;
+
+ const items: Recipient[] = Array.from(followerIds, (id) => {
+ const actorId = new URL(id);
+ return {
+ id: actorId,
+ inboxId: new URL(`${actorId.pathname}/inbox`, actorId),
+ };
+ });
+ return { items };
+ },
+);
+
federation
.setOrderedCollectionDispatcher(
PUBLIC_BOOKMARKS,
@@ -176,12 +184,7 @@ federation
FOLLOWERS_ONLY_BOOKMARKS,
Article,
"/users/{identifier}/collections/followers-only",
- async (ctx, values, cursor) => {
- if (values.identifier !== OWNER) return null;
- if (!await isFollowerRequest(ctx)) {
- return { items: [], nextCursor: null, prevCursor: null };
- }
-
+ (ctx, _values, cursor) => {
return collectionBookmarks(
ctx,
followersOnlyBookmarks(),
@@ -189,31 +192,22 @@ federation
);
},
)
- .setCounter(async (ctx, values) => {
+ .setCounter((_ctx, values) => {
if (values.identifier !== OWNER) return null;
- return await isFollowerRequest(ctx) ? followersOnlyBookmarks().length : 0;
+ return followersOnlyBookmarks().length;
})
- .setFirstCursor(async (ctx, values) => {
- if (values.identifier !== OWNER || !await isFollowerRequest(ctx)) {
- return null;
- }
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
return firstCursor(followersOnlyBookmarks());
})
- .setLastCursor(async (ctx, values) => {
- if (values.identifier !== OWNER || !await isFollowerRequest(ctx)) {
- return null;
- }
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
return lastCursor(followersOnlyBookmarks());
+ })
+ .authorize(async (ctx, values) => {
+ return values.identifier === OWNER && await isFollowerRequest(ctx);
});
-function collectionLink(href: URL, name: string): Link {
- return new Link({
- href,
- rel: "collection",
- name,
- });
-}
-
function publicBookmarks(): Bookmark[] {
return sortBookmarks(
bookmarks.filter((bookmark) => bookmark.visibility === "public"),
@@ -270,6 +264,10 @@ function pageBookmarks(
prevCursor: string | null;
} {
const offset = parseCursor(cursor);
+ if (offset == null || offset >= Math.max(items.length, 1)) {
+ return { items: [], nextCursor: null, prevCursor: null };
+ }
+
return {
items: items
.slice(offset, offset + PAGE_SIZE)
@@ -293,13 +291,15 @@ function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
}
`,
url: bookmarkUrl,
published: bookmark.savedAt,
- to: bookmark.visibility === "public" ? PUBLIC_COLLECTION : undefined,
+ to: bookmark.visibility === "public"
+ ? PUBLIC_COLLECTION
+ : ctx.getFollowersUri(OWNER),
tags: bookmark.tags.map((tag) =>
new Hashtag({
- href: new URL(
- `/tags/${encodeURIComponent(normalizeTag(tag))}`,
- ctx.url,
- ),
+ href: ctx.getCollectionUri(TAGGED_BOOKMARKS, {
+ identifier: OWNER,
+ tag: normalizeTag(tag),
+ }),
name: `#${tag}`,
})
),
@@ -331,9 +331,11 @@ function lastCursor(items: Bookmark[]): string | null {
return String(Math.floor((items.length - 1) / PAGE_SIZE) * PAGE_SIZE);
}
-function parseCursor(cursor: string): number {
- const offset = Number.parseInt(cursor, 10);
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
+function parseCursor(cursor: string): number | null {
+ const offset = Number(cursor);
+ return Number.isSafeInteger(offset) && offset >= 0 && offset % PAGE_SIZE === 0
+ ? offset
+ : null;
}
function normalizeTag(tag: string): string {
@@ -354,11 +356,30 @@ async function fetchActivityJson(path: string): Promise {
return await response.json();
}
+async function fetchStatus(path: string): Promise {
+ const response = await federation.fetch(
+ new Request(new URL(path, "https://example.com"), {
+ headers: { Accept: "application/activity+json" },
+ }),
+ { contextData: undefined },
+ );
+
+ return response.status;
+}
+
async function printActivityJson(label: string, path: string): Promise {
console.log(`\n## ${label}`);
console.log(JSON.stringify(await fetchActivityJson(path), null, 2));
}
+async function printActivityStatus(
+ label: string,
+ path: string,
+): Promise {
+ console.log(`\n## ${label}`);
+ console.log(await fetchStatus(path));
+}
+
if (import.meta.main) {
await printActivityJson("Actor with custom collection links", "/users/alice");
await printActivityJson(
@@ -377,11 +398,11 @@ if (import.meta.main) {
"Tag-filtered ActivityPub bookmarks first page",
"/users/alice/collections/tags/activitypub?cursor=0",
);
- await printActivityJson(
+ await printActivityStatus(
"Followers-only collection requested without a signature",
"/users/alice/collections/followers-only",
);
- await printActivityJson(
+ await printActivityStatus(
"Followers-only first page requested without a signature",
"/users/alice/collections/followers-only?cursor=0",
);
From d6946d18c031d24ac6da60778534821e4707effb Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 10:47:49 +0900
Subject: [PATCH 04/10] Clarify custom collection boundaries
Document that the actor intentionally omits inbox/outbox behavior and
that the demo followers dispatcher is not a production pagination
pattern. Normalize follower actor IDs before checking signed key owners
so the authorization example does not rely on raw string identity.
Keep unknown followers-only collection owners on the missing-collection
path while preserving 401 responses for unsigned requests to Alice's
followers-only collection. Update the README output description for the
followers-only status responses printed by the script.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3140030969
https://github.com/fedify-dev/fedify/pull/722#discussion_r3140040106
https://github.com/fedify-dev/fedify/pull/722#discussion_r3140040112
https://github.com/fedify-dev/fedify/pull/722#discussion_r3140040123
https://github.com/fedify-dev/fedify/pull/722#discussion_r3140040140
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/README.md | 5 +++--
examples/custom-collections/main.ts | 26 +++++++++++++++++++-------
2 files changed, 22 insertions(+), 9 deletions(-)
diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md
index db6df2186..e802591ee 100644
--- a/examples/custom-collections/README.md
+++ b/examples/custom-collections/README.md
@@ -24,5 +24,6 @@ deno task codegen # At very first time only
deno run -A ./main.ts
~~~~
-The output prints the actor document, collection metadata responses, and page
-responses for the example routes.
+The output prints the actor document, collection metadata and page responses
+for the public and tag-filtered routes, and the HTTP status codes returned
+for unsigned requests to the followers-only collection.
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index 3b9d19900..9af8e6558 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -76,10 +76,12 @@ const bookmarks: Bookmark[] = [
},
];
-export const followerIds = new Set([
- "https://remote.example/users/bob",
- "https://social.example/users/carol",
-]);
+export const followerIds = new Set(
+ [
+ "https://remote.example/users/bob",
+ "https://social.example/users/carol",
+ ].map(normalizeActorId),
+);
export const federation = createFederation({
kv: new MemoryKvStore(),
@@ -89,6 +91,8 @@ federation
.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
if (identifier !== OWNER) return null;
+ // inbox/outbox are omitted here; use setInboxListeners() and
+ // setOutboxDispatcher() for full ActivityPub actors.
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
@@ -112,6 +116,7 @@ federation.setFollowersDispatcher(
(_ctx, identifier) => {
if (identifier !== OWNER) return null;
+ // For large follower lists, add counters and cursor callbacks as below.
const items: Recipient[] = Array.from(followerIds, (id) => {
const actorId = new URL(id);
return {
@@ -184,7 +189,9 @@ federation
FOLLOWERS_ONLY_BOOKMARKS,
Article,
"/users/{identifier}/collections/followers-only",
- (ctx, _values, cursor) => {
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+
return collectionBookmarks(
ctx,
followersOnlyBookmarks(),
@@ -205,7 +212,8 @@ federation
return lastCursor(followersOnlyBookmarks());
})
.authorize(async (ctx, values) => {
- return values.identifier === OWNER && await isFollowerRequest(ctx);
+ if (values.identifier !== OWNER) return true;
+ return await isFollowerRequest(ctx);
});
function publicBookmarks(): Bookmark[] {
@@ -319,7 +327,7 @@ async function isFollowerRequest(ctx: RequestContext): Promise {
const signedKeyOwner = await ctx.getSignedKeyOwner();
return signedKeyOwner?.id == null
? false
- : followerIds.has(signedKeyOwner.id.href);
+ : followerIds.has(normalizeActorId(signedKeyOwner.id));
}
function firstCursor(items: Bookmark[]): string | null {
@@ -342,6 +350,10 @@ function normalizeTag(tag: string): string {
return tag.trim().toLowerCase();
}
+function normalizeActorId(id: string | URL): string {
+ return new URL(id).href;
+}
+
async function fetchActivityJson(path: string): Promise {
const response = await federation.fetch(
new Request(new URL(path, "https://example.com"), {
From 08157ea50a4556bfdd2be2a692fc05b77d4251ad Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 11:12:32 +0900
Subject: [PATCH 05/10] Tighten bookmark Article output
Mark generated bookmark Article content as HTML so consumers do not have
to infer the media type from the markup. Also polish the setup comment in
the custom collections README.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141129182
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141129189
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/README.md | 2 +-
examples/custom-collections/main.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md
index e802591ee..f45c47c31 100644
--- a/examples/custom-collections/README.md
+++ b/examples/custom-collections/README.md
@@ -20,7 +20,7 @@ The script demonstrates three patterns:
Run it from this directory:
~~~~ sh
-deno task codegen # At very first time only
+deno task codegen # Only the first time
deno run -A ./main.ts
~~~~
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index 9af8e6558..def2d37b0 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -297,6 +297,7 @@ function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
content: `${
escapeHtml(bookmark.title)
}
`,
+ mediaType: "text/html",
url: bookmarkUrl,
published: bookmark.savedAt,
to: bookmark.visibility === "public"
From 028229d1ad6510d43d6b4d2ba8a71cee24caeffd Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 11:31:39 +0900
Subject: [PATCH 06/10] Keep bookmark summaries plain
Keep bookmark summaries as plain text while leaving HTML escaping on the
generated Article content. The content field already declares text/html,
so escaping belongs only to values interpolated into that HTML string.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141155906
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/main.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index def2d37b0..5c45920f2 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -293,7 +293,7 @@ function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
attribution: ctx.getActorUri(OWNER),
name: bookmark.title,
- summary: escapeHtml(bookmark.note),
+ summary: bookmark.note,
content: `${
escapeHtml(bookmark.title)
}
`,
From 2642c585766d9b6bf700052f870053978cd20361 Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 22:08:51 +0900
Subject: [PATCH 07/10] Split custom collections example
Move the Fedify dispatcher setup and bookmark helpers out of main.ts so the
example has clearer module boundaries. This keeps main.ts focused on the
runnable output while federation.ts owns the Federation configuration and
lib.ts owns the sample data helpers.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141613216
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/federation.ts | 252 +++++++++++++
examples/custom-collections/lib.ts | 119 +++++++
examples/custom-collections/main.ts | 409 ++--------------------
3 files changed, 397 insertions(+), 383 deletions(-)
create mode 100644 examples/custom-collections/federation.ts
create mode 100644 examples/custom-collections/lib.ts
diff --git a/examples/custom-collections/federation.ts b/examples/custom-collections/federation.ts
new file mode 100644
index 000000000..697a31851
--- /dev/null
+++ b/examples/custom-collections/federation.ts
@@ -0,0 +1,252 @@
+import {
+ createFederation,
+ MemoryKvStore,
+ type RequestContext,
+} from "@fedify/fedify";
+import {
+ Article,
+ Hashtag,
+ Person,
+ PUBLIC_COLLECTION,
+ type Recipient,
+} from "@fedify/vocab";
+import {
+ type Bookmark,
+ firstCursor,
+ followerIds,
+ followersOnlyBookmarks,
+ lastCursor,
+ normalizeActorId,
+ normalizeTag,
+ OWNER,
+ PAGE_SIZE,
+ parseCursor,
+ publicBookmarks,
+ taggedBookmarks,
+} from "./lib.ts";
+
+const PUBLIC_BOOKMARKS = "public-bookmarks";
+const TAGGED_BOOKMARKS = "tagged-bookmarks";
+const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks";
+
+const federation = createFederation({
+ kv: new MemoryKvStore(),
+});
+
+federation
+ .setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
+ if (identifier !== OWNER) return null;
+
+ // inbox/outbox are omitted here; use setInboxListeners() and
+ // setOutboxDispatcher() for full ActivityPub actors.
+ return new Person({
+ id: ctx.getActorUri(identifier),
+ preferredUsername: identifier,
+ name: "Alice's bookmarks",
+ summary: "A single-user bookmark log with custom collection examples.",
+ url: new URL(`/users/${identifier}`, ctx.url),
+ followers: ctx.getFollowersUri(identifier),
+ streams: [
+ ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
+ ctx.getCollectionUri(TAGGED_BOOKMARKS, {
+ identifier,
+ tag: "activitypub",
+ }),
+ ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
+ ],
+ });
+ });
+
+federation.setFollowersDispatcher(
+ "/users/{identifier}/followers",
+ (_ctx, identifier) => {
+ if (identifier !== OWNER) return null;
+
+ // For large follower lists, add counters and cursor callbacks as below.
+ const items: Recipient[] = Array.from(followerIds, (id) => {
+ const actorId = new URL(id);
+ return {
+ id: actorId,
+ inboxId: new URL(`${actorId.pathname}/inbox`, actorId),
+ };
+ });
+ return { items };
+ },
+);
+
+federation
+ .setOrderedCollectionDispatcher(
+ PUBLIC_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/public",
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+
+ return collectionBookmarks(
+ ctx,
+ publicBookmarks(),
+ cursor,
+ );
+ },
+ )
+ .setCounter((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return publicBookmarks().length;
+ })
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return firstCursor(publicBookmarks());
+ })
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return lastCursor(publicBookmarks());
+ });
+
+federation
+ .setOrderedCollectionDispatcher(
+ TAGGED_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/tags/{tag}",
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+
+ return collectionBookmarks(
+ ctx,
+ taggedBookmarks(values.tag),
+ cursor,
+ );
+ },
+ )
+ .setCounter((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return taggedBookmarks(values.tag).length;
+ })
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return firstCursor(taggedBookmarks(values.tag));
+ })
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return lastCursor(taggedBookmarks(values.tag));
+ });
+
+federation
+ .setOrderedCollectionDispatcher(
+ FOLLOWERS_ONLY_BOOKMARKS,
+ Article,
+ "/users/{identifier}/collections/followers-only",
+ (ctx, values, cursor) => {
+ if (values.identifier !== OWNER) return null;
+
+ return collectionBookmarks(
+ ctx,
+ followersOnlyBookmarks(),
+ cursor,
+ );
+ },
+ )
+ .setCounter((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return followersOnlyBookmarks().length;
+ })
+ .setFirstCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return firstCursor(followersOnlyBookmarks());
+ })
+ .setLastCursor((_ctx, values) => {
+ if (values.identifier !== OWNER) return null;
+ return lastCursor(followersOnlyBookmarks());
+ })
+ .authorize(async (ctx, values) => {
+ if (values.identifier !== OWNER) return true;
+ return await isFollowerRequest(ctx);
+ });
+
+function collectionBookmarks(
+ ctx: RequestContext,
+ items: Bookmark[],
+ cursor: string | null,
+): {
+ items: Article[];
+ nextCursor: string | null;
+ prevCursor: string | null;
+} {
+ return cursor == null
+ ? {
+ items: items.map((bookmark) => toArticle(ctx, bookmark)),
+ nextCursor: null,
+ prevCursor: null,
+ }
+ : pageBookmarks(ctx, items, cursor);
+}
+
+function pageBookmarks(
+ ctx: RequestContext,
+ items: Bookmark[],
+ cursor: string,
+): {
+ items: Article[];
+ nextCursor: string | null;
+ prevCursor: string | null;
+} {
+ const offset = parseCursor(cursor);
+ if (offset == null || offset >= Math.max(items.length, 1)) {
+ return { items: [], nextCursor: null, prevCursor: null };
+ }
+
+ return {
+ items: items
+ .slice(offset, offset + PAGE_SIZE)
+ .map((bookmark) => toArticle(ctx, bookmark)),
+ nextCursor: offset + PAGE_SIZE < items.length
+ ? String(offset + PAGE_SIZE)
+ : null,
+ prevCursor: offset > 0 ? String(Math.max(0, offset - PAGE_SIZE)) : null,
+ };
+}
+
+function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
+ const bookmarkUrl = new URL(bookmark.href);
+ return new Article({
+ id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
+ attribution: ctx.getActorUri(OWNER),
+ name: bookmark.title,
+ summary: bookmark.note,
+ content: `${
+ escapeHtml(bookmark.title)
+ }
`,
+ mediaType: "text/html",
+ url: bookmarkUrl,
+ published: bookmark.savedAt,
+ to: bookmark.visibility === "public"
+ ? PUBLIC_COLLECTION
+ : ctx.getFollowersUri(OWNER),
+ tags: bookmark.tags.map((tag) =>
+ new Hashtag({
+ href: ctx.getCollectionUri(TAGGED_BOOKMARKS, {
+ identifier: OWNER,
+ tag: normalizeTag(tag),
+ }),
+ name: `#${tag}`,
+ })
+ ),
+ });
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+async function isFollowerRequest(ctx: RequestContext): Promise {
+ const signedKeyOwner = await ctx.getSignedKeyOwner();
+ return signedKeyOwner?.id == null
+ ? false
+ : followerIds.has(normalizeActorId(signedKeyOwner.id));
+}
+
+export default federation;
diff --git a/examples/custom-collections/lib.ts b/examples/custom-collections/lib.ts
new file mode 100644
index 000000000..1268723cc
--- /dev/null
+++ b/examples/custom-collections/lib.ts
@@ -0,0 +1,119 @@
+export const OWNER = "alice";
+export const PAGE_SIZE = 2;
+
+export interface Bookmark {
+ id: string;
+ title: string;
+ href: string;
+ note: string;
+ tags: string[];
+ visibility: "public" | "followers";
+ savedAt: Temporal.Instant;
+}
+
+const bookmarks: Bookmark[] = [
+ {
+ id: "fedify-manual",
+ title: "Fedify manual",
+ href: "https://fedify.dev/manual/",
+ note: "Reference material for building ActivityPub servers with Fedify.",
+ tags: ["fedify", "activitypub"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-20T09:00:00Z"),
+ },
+ {
+ id: "activitypub-spec",
+ title: "ActivityPub specification",
+ href: "https://www.w3.org/TR/activitypub/",
+ note: "The W3C ActivityPub recommendation.",
+ tags: ["activitypub", "spec"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-19T12:00:00Z"),
+ },
+ {
+ id: "uri-template",
+ title: "URI Template",
+ href: "https://www.rfc-editor.org/rfc/rfc6570",
+ note: "How Fedify dispatcher path parameters are expanded.",
+ tags: ["spec", "routing"],
+ visibility: "public",
+ savedAt: Temporal.Instant.from("2026-04-18T16:30:00Z"),
+ },
+ {
+ id: "private-reading-list",
+ title: "Private reading list",
+ href: "https://example.net/reading-list",
+ note: "A bookmark visible only to accepted followers.",
+ tags: ["fedify", "reading"],
+ visibility: "followers",
+ savedAt: Temporal.Instant.from("2026-04-17T08:15:00Z"),
+ },
+ {
+ id: "moderation-notes",
+ title: "Moderation notes",
+ href: "https://example.net/moderation",
+ note: "Follower-facing notes for a small community server.",
+ tags: ["activitypub", "moderation"],
+ visibility: "followers",
+ savedAt: Temporal.Instant.from("2026-04-16T10:45:00Z"),
+ },
+];
+
+export const followerIds = new Set(
+ [
+ "https://remote.example/users/bob",
+ "https://social.example/users/carol",
+ ].map(normalizeActorId),
+);
+
+export function publicBookmarks(): Bookmark[] {
+ return sortBookmarks(
+ bookmarks.filter((bookmark) => bookmark.visibility === "public"),
+ );
+}
+
+export function followersOnlyBookmarks(): Bookmark[] {
+ return sortBookmarks(
+ bookmarks.filter((bookmark) => bookmark.visibility === "followers"),
+ );
+}
+
+export function taggedBookmarks(tag: string): Bookmark[] {
+ const normalizedTag = normalizeTag(tag);
+ return sortBookmarks(
+ bookmarks.filter((bookmark) =>
+ bookmark.visibility === "public" &&
+ bookmark.tags.some((itemTag) => normalizeTag(itemTag) === normalizedTag)
+ ),
+ );
+}
+
+function sortBookmarks(items: Bookmark[]): Bookmark[] {
+ return items.toSorted((a, b) =>
+ Temporal.Instant.compare(b.savedAt, a.savedAt)
+ );
+}
+
+export function firstCursor(items: Bookmark[]): string | null {
+ return items.length < 1 ? null : "0";
+}
+
+export function lastCursor(items: Bookmark[]): string | null {
+ if (items.length < 1) return null;
+ return String(Math.floor((items.length - 1) / PAGE_SIZE) * PAGE_SIZE);
+}
+
+export function parseCursor(cursor: string): number | null {
+ const offset = Number(cursor);
+ return Number.isSafeInteger(offset) && offset >= 0 && offset % PAGE_SIZE === 0
+ ? offset
+ : null;
+}
+
+export function normalizeTag(tag: string): string {
+ return tag.trim().toLowerCase();
+}
+
+export function normalizeActorId(id: string | URL): string {
+ return new URL(id).href;
+}
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index 5c45920f2..a3f898c44 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -1,359 +1,4 @@
-import {
- createFederation,
- MemoryKvStore,
- type RequestContext,
-} from "@fedify/fedify";
-import {
- Article,
- Hashtag,
- Person,
- PUBLIC_COLLECTION,
- type Recipient,
-} from "@fedify/vocab";
-
-const OWNER = "alice";
-const PAGE_SIZE = 2;
-
-const PUBLIC_BOOKMARKS = "public-bookmarks";
-const TAGGED_BOOKMARKS = "tagged-bookmarks";
-const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks";
-
-interface Bookmark {
- id: string;
- title: string;
- href: string;
- note: string;
- tags: string[];
- visibility: "public" | "followers";
- savedAt: Temporal.Instant;
-}
-
-const bookmarks: Bookmark[] = [
- {
- id: "fedify-manual",
- title: "Fedify manual",
- href: "https://fedify.dev/manual/",
- note: "Reference material for building ActivityPub servers with Fedify.",
- tags: ["fedify", "activitypub"],
- visibility: "public",
- savedAt: Temporal.Instant.from("2026-04-20T09:00:00Z"),
- },
- {
- id: "activitypub-spec",
- title: "ActivityPub specification",
- href: "https://www.w3.org/TR/activitypub/",
- note: "The W3C ActivityPub recommendation.",
- tags: ["activitypub", "spec"],
- visibility: "public",
- savedAt: Temporal.Instant.from("2026-04-19T12:00:00Z"),
- },
- {
- id: "uri-template",
- title: "URI Template",
- href: "https://www.rfc-editor.org/rfc/rfc6570",
- note: "How Fedify dispatcher path parameters are expanded.",
- tags: ["spec", "routing"],
- visibility: "public",
- savedAt: Temporal.Instant.from("2026-04-18T16:30:00Z"),
- },
- {
- id: "private-reading-list",
- title: "Private reading list",
- href: "https://example.net/reading-list",
- note: "A bookmark visible only to accepted followers.",
- tags: ["fedify", "reading"],
- visibility: "followers",
- savedAt: Temporal.Instant.from("2026-04-17T08:15:00Z"),
- },
- {
- id: "moderation-notes",
- title: "Moderation notes",
- href: "https://example.net/moderation",
- note: "Follower-facing notes for a small community server.",
- tags: ["activitypub", "moderation"],
- visibility: "followers",
- savedAt: Temporal.Instant.from("2026-04-16T10:45:00Z"),
- },
-];
-
-export const followerIds = new Set(
- [
- "https://remote.example/users/bob",
- "https://social.example/users/carol",
- ].map(normalizeActorId),
-);
-
-export const federation = createFederation({
- kv: new MemoryKvStore(),
-});
-
-federation
- .setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
- if (identifier !== OWNER) return null;
-
- // inbox/outbox are omitted here; use setInboxListeners() and
- // setOutboxDispatcher() for full ActivityPub actors.
- return new Person({
- id: ctx.getActorUri(identifier),
- preferredUsername: identifier,
- name: "Alice's bookmarks",
- summary: "A single-user bookmark log with custom collection examples.",
- url: new URL(`/users/${identifier}`, ctx.url),
- followers: ctx.getFollowersUri(identifier),
- streams: [
- ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
- ctx.getCollectionUri(TAGGED_BOOKMARKS, {
- identifier,
- tag: "activitypub",
- }),
- ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
- ],
- });
- });
-
-federation.setFollowersDispatcher(
- "/users/{identifier}/followers",
- (_ctx, identifier) => {
- if (identifier !== OWNER) return null;
-
- // For large follower lists, add counters and cursor callbacks as below.
- const items: Recipient[] = Array.from(followerIds, (id) => {
- const actorId = new URL(id);
- return {
- id: actorId,
- inboxId: new URL(`${actorId.pathname}/inbox`, actorId),
- };
- });
- return { items };
- },
-);
-
-federation
- .setOrderedCollectionDispatcher(
- PUBLIC_BOOKMARKS,
- Article,
- "/users/{identifier}/collections/public",
- (ctx, values, cursor) => {
- if (values.identifier !== OWNER) return null;
-
- return collectionBookmarks(
- ctx,
- publicBookmarks(),
- cursor,
- );
- },
- )
- .setCounter((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return publicBookmarks().length;
- })
- .setFirstCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return firstCursor(publicBookmarks());
- })
- .setLastCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return lastCursor(publicBookmarks());
- });
-
-federation
- .setOrderedCollectionDispatcher(
- TAGGED_BOOKMARKS,
- Article,
- "/users/{identifier}/collections/tags/{tag}",
- (ctx, values, cursor) => {
- if (values.identifier !== OWNER) return null;
-
- return collectionBookmarks(
- ctx,
- taggedBookmarks(values.tag),
- cursor,
- );
- },
- )
- .setCounter((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return taggedBookmarks(values.tag).length;
- })
- .setFirstCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return firstCursor(taggedBookmarks(values.tag));
- })
- .setLastCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return lastCursor(taggedBookmarks(values.tag));
- });
-
-federation
- .setOrderedCollectionDispatcher(
- FOLLOWERS_ONLY_BOOKMARKS,
- Article,
- "/users/{identifier}/collections/followers-only",
- (ctx, values, cursor) => {
- if (values.identifier !== OWNER) return null;
-
- return collectionBookmarks(
- ctx,
- followersOnlyBookmarks(),
- cursor,
- );
- },
- )
- .setCounter((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return followersOnlyBookmarks().length;
- })
- .setFirstCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return firstCursor(followersOnlyBookmarks());
- })
- .setLastCursor((_ctx, values) => {
- if (values.identifier !== OWNER) return null;
- return lastCursor(followersOnlyBookmarks());
- })
- .authorize(async (ctx, values) => {
- if (values.identifier !== OWNER) return true;
- return await isFollowerRequest(ctx);
- });
-
-function publicBookmarks(): Bookmark[] {
- return sortBookmarks(
- bookmarks.filter((bookmark) => bookmark.visibility === "public"),
- );
-}
-
-function followersOnlyBookmarks(): Bookmark[] {
- return sortBookmarks(
- bookmarks.filter((bookmark) => bookmark.visibility === "followers"),
- );
-}
-
-function taggedBookmarks(tag: string): Bookmark[] {
- const normalizedTag = normalizeTag(tag);
- return sortBookmarks(
- bookmarks.filter((bookmark) =>
- bookmark.visibility === "public" &&
- bookmark.tags.some((itemTag) => normalizeTag(itemTag) === normalizedTag)
- ),
- );
-}
-
-function sortBookmarks(items: Bookmark[]): Bookmark[] {
- return items.toSorted((a, b) =>
- Temporal.Instant.compare(b.savedAt, a.savedAt)
- );
-}
-
-function collectionBookmarks(
- ctx: RequestContext,
- items: Bookmark[],
- cursor: string | null,
-): {
- items: Article[];
- nextCursor: string | null;
- prevCursor: string | null;
-} {
- return cursor == null
- ? {
- items: items.map((bookmark) => toArticle(ctx, bookmark)),
- nextCursor: null,
- prevCursor: null,
- }
- : pageBookmarks(ctx, items, cursor);
-}
-
-function pageBookmarks(
- ctx: RequestContext,
- items: Bookmark[],
- cursor: string,
-): {
- items: Article[];
- nextCursor: string | null;
- prevCursor: string | null;
-} {
- const offset = parseCursor(cursor);
- if (offset == null || offset >= Math.max(items.length, 1)) {
- return { items: [], nextCursor: null, prevCursor: null };
- }
-
- return {
- items: items
- .slice(offset, offset + PAGE_SIZE)
- .map((bookmark) => toArticle(ctx, bookmark)),
- nextCursor: offset + PAGE_SIZE < items.length
- ? String(offset + PAGE_SIZE)
- : null,
- prevCursor: offset > 0 ? String(Math.max(0, offset - PAGE_SIZE)) : null,
- };
-}
-
-function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
- const bookmarkUrl = new URL(bookmark.href);
- return new Article({
- id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
- attribution: ctx.getActorUri(OWNER),
- name: bookmark.title,
- summary: bookmark.note,
- content: `${
- escapeHtml(bookmark.title)
- }
`,
- mediaType: "text/html",
- url: bookmarkUrl,
- published: bookmark.savedAt,
- to: bookmark.visibility === "public"
- ? PUBLIC_COLLECTION
- : ctx.getFollowersUri(OWNER),
- tags: bookmark.tags.map((tag) =>
- new Hashtag({
- href: ctx.getCollectionUri(TAGGED_BOOKMARKS, {
- identifier: OWNER,
- tag: normalizeTag(tag),
- }),
- name: `#${tag}`,
- })
- ),
- });
-}
-
-function escapeHtml(value: string): string {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
-async function isFollowerRequest(ctx: RequestContext): Promise {
- const signedKeyOwner = await ctx.getSignedKeyOwner();
- return signedKeyOwner?.id == null
- ? false
- : followerIds.has(normalizeActorId(signedKeyOwner.id));
-}
-
-function firstCursor(items: Bookmark[]): string | null {
- return items.length < 1 ? null : "0";
-}
-
-function lastCursor(items: Bookmark[]): string | null {
- if (items.length < 1) return null;
- return String(Math.floor((items.length - 1) / PAGE_SIZE) * PAGE_SIZE);
-}
-
-function parseCursor(cursor: string): number | null {
- const offset = Number(cursor);
- return Number.isSafeInteger(offset) && offset >= 0 && offset % PAGE_SIZE === 0
- ? offset
- : null;
-}
-
-function normalizeTag(tag: string): string {
- return tag.trim().toLowerCase();
-}
-
-function normalizeActorId(id: string | URL): string {
- return new URL(id).href;
-}
+import federation from "./federation.ts";
async function fetchActivityJson(path: string): Promise {
const response = await federation.fetch(
@@ -393,30 +38,28 @@ async function printActivityStatus(
console.log(await fetchStatus(path));
}
-if (import.meta.main) {
- await printActivityJson("Actor with custom collection links", "/users/alice");
- await printActivityJson(
- "Public bookmarks collection",
- "/users/alice/collections/public",
- );
- await printActivityJson(
- "Public bookmarks first page",
- "/users/alice/collections/public?cursor=0",
- );
- await printActivityJson(
- "Tag-filtered ActivityPub bookmarks collection",
- "/users/alice/collections/tags/activitypub",
- );
- await printActivityJson(
- "Tag-filtered ActivityPub bookmarks first page",
- "/users/alice/collections/tags/activitypub?cursor=0",
- );
- await printActivityStatus(
- "Followers-only collection requested without a signature",
- "/users/alice/collections/followers-only",
- );
- await printActivityStatus(
- "Followers-only first page requested without a signature",
- "/users/alice/collections/followers-only?cursor=0",
- );
-}
+await printActivityJson("Actor with custom collection links", "/users/alice");
+await printActivityJson(
+ "Public bookmarks collection",
+ "/users/alice/collections/public",
+);
+await printActivityJson(
+ "Public bookmarks first page",
+ "/users/alice/collections/public?cursor=0",
+);
+await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks collection",
+ "/users/alice/collections/tags/activitypub",
+);
+await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks first page",
+ "/users/alice/collections/tags/activitypub?cursor=0",
+);
+await printActivityStatus(
+ "Followers-only collection requested without a signature",
+ "/users/alice/collections/followers-only",
+);
+await printActivityStatus(
+ "Followers-only first page requested without a signature",
+ "/users/alice/collections/followers-only?cursor=0",
+);
From 46cc69c6a8d9fe28e8084e2d29fa6078ba8ee58b Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 22:18:49 +0900
Subject: [PATCH 08/10] Tidy custom collection runner
Keep importing the custom collections runner side-effect free by restoring
an import.meta.main guard around the demo output. Also cancel the status
response body after recording the HTTP status so the in-process example does
not leave response streams open longer than needed.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141981276
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141981283
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/main.ts | 60 ++++++++++++++++-------------
1 file changed, 34 insertions(+), 26 deletions(-)
diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts
index a3f898c44..80de397df 100644
--- a/examples/custom-collections/main.ts
+++ b/examples/custom-collections/main.ts
@@ -22,7 +22,9 @@ async function fetchStatus(path: string): Promise {
{ contextData: undefined },
);
- return response.status;
+ const status = response.status;
+ await response.body?.cancel();
+ return status;
}
async function printActivityJson(label: string, path: string): Promise {
@@ -38,28 +40,34 @@ async function printActivityStatus(
console.log(await fetchStatus(path));
}
-await printActivityJson("Actor with custom collection links", "/users/alice");
-await printActivityJson(
- "Public bookmarks collection",
- "/users/alice/collections/public",
-);
-await printActivityJson(
- "Public bookmarks first page",
- "/users/alice/collections/public?cursor=0",
-);
-await printActivityJson(
- "Tag-filtered ActivityPub bookmarks collection",
- "/users/alice/collections/tags/activitypub",
-);
-await printActivityJson(
- "Tag-filtered ActivityPub bookmarks first page",
- "/users/alice/collections/tags/activitypub?cursor=0",
-);
-await printActivityStatus(
- "Followers-only collection requested without a signature",
- "/users/alice/collections/followers-only",
-);
-await printActivityStatus(
- "Followers-only first page requested without a signature",
- "/users/alice/collections/followers-only?cursor=0",
-);
+async function main(): Promise {
+ await printActivityJson("Actor with custom collection links", "/users/alice");
+ await printActivityJson(
+ "Public bookmarks collection",
+ "/users/alice/collections/public",
+ );
+ await printActivityJson(
+ "Public bookmarks first page",
+ "/users/alice/collections/public?cursor=0",
+ );
+ await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks collection",
+ "/users/alice/collections/tags/activitypub",
+ );
+ await printActivityJson(
+ "Tag-filtered ActivityPub bookmarks first page",
+ "/users/alice/collections/tags/activitypub?cursor=0",
+ );
+ await printActivityStatus(
+ "Followers-only collection requested without a signature",
+ "/users/alice/collections/followers-only",
+ );
+ await printActivityStatus(
+ "Followers-only first page requested without a signature",
+ "/users/alice/collections/followers-only?cursor=0",
+ );
+}
+
+if (import.meta.main) {
+ await main();
+}
From a9bf404fcbdda439991637640b44384e1cf627cc Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sat, 25 Apr 2026 22:44:29 +0900
Subject: [PATCH 09/10] Treat bad follower signatures as denied
Handle getSignedKeyOwner() failures as unauthenticated follower-only
collection requests. Malformed signature input should keep the cookbook's
restricted collection path on the same unauthorized branch instead of
surfacing as a server error.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3141999497
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/federation.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/examples/custom-collections/federation.ts b/examples/custom-collections/federation.ts
index 697a31851..6147928a6 100644
--- a/examples/custom-collections/federation.ts
+++ b/examples/custom-collections/federation.ts
@@ -243,10 +243,14 @@ function escapeHtml(value: string): string {
}
async function isFollowerRequest(ctx: RequestContext): Promise {
- const signedKeyOwner = await ctx.getSignedKeyOwner();
- return signedKeyOwner?.id == null
- ? false
- : followerIds.has(normalizeActorId(signedKeyOwner.id));
+ try {
+ const signedKeyOwner = await ctx.getSignedKeyOwner();
+ return signedKeyOwner?.id == null
+ ? false
+ : followerIds.has(normalizeActorId(signedKeyOwner.id));
+ } catch {
+ return false;
+ }
}
export default federation;
From 100192f281437a7cddf9dc641221031002e684a0 Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Sun, 26 Apr 2026 00:00:32 +0900
Subject: [PATCH 10/10] Use table-driven HTML escaping
Keep the cookbook's HTML escaping helper compact by replacing matched
characters through a shared lookup table. This avoids chaining multiple
string replacements while preserving the same escaped output.
https://github.com/fedify-dev/fedify/pull/722#discussion_r3142130951
Assisted-by: Codex:gpt-5.5
---
examples/custom-collections/federation.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/examples/custom-collections/federation.ts b/examples/custom-collections/federation.ts
index 6147928a6..296da3763 100644
--- a/examples/custom-collections/federation.ts
+++ b/examples/custom-collections/federation.ts
@@ -28,6 +28,13 @@ import {
const PUBLIC_BOOKMARKS = "public-bookmarks";
const TAGGED_BOOKMARKS = "tagged-bookmarks";
const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks";
+const HTML_ESCAPES: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
const federation = createFederation({
kv: new MemoryKvStore(),
@@ -234,12 +241,7 @@ function toArticle(ctx: RequestContext, bookmark: Bookmark): Article {
}
function escapeHtml(value: string): string {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
+ return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
}
async function isFollowerRequest(ctx: RequestContext): Promise {