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 {