UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,541 lines • 151 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { n as createOutboxContext, r as createRequestContext, t as createInboxContext } from "../context-DVoTs_wM.mjs"; import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs"; import "../std__assert-BBjXFNOb.mjs"; import { n as assertGreaterOrEqual, t as assertRejects } from "../assert_rejects-DN60FHPX.mjs"; import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs"; import { t as assert } from "../assert-OguE97r2.mjs"; import { r as parseAcceptSignature } from "../accept-CPkZzmGN.mjs"; import { s as signRequest } from "../http-PCt0862M.mjs"; import { a as rsaPrivateKey3, c as rsaPublicKey3, s as rsaPublicKey2 } from "../keys-DGu1NFwu.mjs"; import { a as compactJsonLd, p as signJsonLd } from "../ld-Dcw2skfn.mjs"; import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs"; import { c as handleActor, d as handleInbox, f as handleObject, h as respondWithObjectIfAcceptable, l as handleCollection, m as respondWithObject, o as createFederation, p as handleOutbox, u as handleCustomCollection } from "../middleware-WU_5fbGS.mjs"; import { t as ActivityListenerSet } from "../activity-listener-tztVvlNb.mjs"; import { Activity, Create, Note, Person, Tombstone } from "@fedify/vocab"; import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture"; import { FetchError } from "@fedify/vocab-runtime"; //#region src/federation/handler.test.ts const QUOTE_CONTEXT_TERMS = { QuoteAuthorization: "https://w3id.org/fep/044f#QuoteAuthorization", quote: { "@id": "https://w3id.org/fep/044f#quote", "@type": "@id" }, quoteAuthorization: { "@id": "https://w3id.org/fep/044f#quoteAuthorization", "@type": "@id" } }; const WRAPPER_QUOTE_CONTEXT_TERMS = { ...QUOTE_CONTEXT_TERMS, QuoteRequest: "https://w3id.org/fep/044f#QuoteRequest" }; test("handleActor()", async () => { const federation = createFederation({ kv: new MemoryKvStore() }); const deletedAt = Temporal.Instant.from("2024-01-15T00:00:00Z"); let context = createRequestContext({ federation, data: void 0, url: new URL("https://example.com/"), getActorUri(identifier) { return new URL(`https://example.com/users/${identifier}`); } }); const actorDispatcher = (ctx, identifier) => { if (identifier !== "someone") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Someone" }); }; const tombstoneDispatcher = (ctx, identifier) => { if (identifier !== "gone") return null; return new Tombstone({ id: ctx.getActorUri(identifier), formerType: Person, deleted: deletedAt }); }; let onNotFoundCalled = null; const onNotFound = (request) => { onNotFoundCalled = request; return new Response("Not found", { status: 404 }); }; let onUnauthorizedCalled = null; const onUnauthorized = (request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleActor(context.request, { context, identifier: "someone", onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleActor(context.request, { context, identifier: "no-one", actorDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext({ ...context, request: new Request(context.url, { headers: { Accept: "application/activity+json" } }) }); response = await handleActor(context.request, { context, identifier: "someone", actorDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", "https://w3id.org/security/data-integrity/v1", "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1", "https://gotosocial.org/ns", { alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" }, manuallyApprovesFollowers: "as:manuallyApprovesFollowers", movedTo: { "@id": "as:movedTo", "@type": "@id" }, featured: { "@id": "toot:featured", "@type": "@id" }, featuredTags: { "@id": "toot:featuredTags", "@type": "@id" }, discoverable: "toot:discoverable", indexable: "toot:indexable", memorial: "toot:memorial", suspended: "toot:suspended", toot: "http://joinmastodon.org/ns#", schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value", misskey: "https://misskey-hub.net/ns#", _misskey_followedMessage: "misskey:_misskey_followedMessage", isCat: "misskey:isCat", Emoji: "toot:Emoji" } ], id: "https://example.com/users/someone", type: "Person", name: "Someone" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleActor(context.request, { context, identifier: "no-one", actorDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleActor(context.request, { context, identifier: "someone", actorDispatcher, authorizePredicate: async (ctx, _handle) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null, onNotFound, onUnauthorized }); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext({ ...context, getSignedKey: () => Promise.resolve(rsaPublicKey2), getSignedKeyOwner: () => Promise.resolve(new Person({})) }); response = await handleActor(context.request, { context, identifier: "someone", actorDispatcher, authorizePredicate: async (ctx, _handle) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", "https://w3id.org/security/data-integrity/v1", "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1", "https://gotosocial.org/ns", { alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" }, manuallyApprovesFollowers: "as:manuallyApprovesFollowers", movedTo: { "@id": "as:movedTo", "@type": "@id" }, featured: { "@id": "toot:featured", "@type": "@id" }, featuredTags: { "@id": "toot:featuredTags", "@type": "@id" }, discoverable: "toot:discoverable", indexable: "toot:indexable", memorial: "toot:memorial", suspended: "toot:suspended", toot: "http://joinmastodon.org/ns#", schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value", misskey: "https://misskey-hub.net/ns#", _misskey_followedMessage: "misskey:_misskey_followedMessage", isCat: "misskey:isCat", Emoji: "toot:Emoji" } ], id: "https://example.com/users/someone", type: "Person", name: "Someone" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleActor(context.request, { context, identifier: "gone", actorDispatcher: tombstoneDispatcher, authorizePredicate: () => false, onNotFound, onUnauthorized }); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; response = await handleActor(context.request, { context, identifier: "gone", actorDispatcher: tombstoneDispatcher, authorizePredicate: () => true, onNotFound, onUnauthorized }); assertEquals(response.status, 410); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(response.headers.get("Vary"), "Accept"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns" ], id: "https://example.com/users/gone", type: "Tombstone", formerType: "as:Person", deleted: "2024-01-15T00:00:00Z" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); }); test("handleObject()", async () => { let context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore() }), data: void 0, url: new URL("https://example.com/"), getObjectUri(_cls, values) { return new URL(`https://example.com/users/${values.identifier}/notes/${values.id}`); } }); const objectDispatcher = (ctx, values) => { if (values.identifier !== "someone" || values.id !== "123") return null; return new Note({ id: ctx.getObjectUri(Note, values), summary: "Hello, world!" }); }; let onNotFoundCalled = null; const onNotFound = (request) => { onNotFoundCalled = request; return new Response("Not found", { status: 404 }); }; let onUnauthorizedCalled = null; const onUnauthorized = (request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleObject(context.request, { context, values: { identifier: "someone", id: "123" }, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleObject(context.request, { context, values: { identifier: "someone", id: "123" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleObject(context.request, { context, values: { identifier: "no-one", id: "123" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleObject(context.request, { context, values: { identifier: "someone", id: "not-exist" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext({ ...context, request: new Request(context.url, { headers: { Accept: "application/activity+json" } }) }); response = await handleObject(context.request, { context, values: { identifier: "someone", id: "123" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", toot: "http://joinmastodon.org/ns#", _misskey_quote: "misskey:_misskey_quote", fedibird: "http://fedibird.com/ns#", misskey: "https://misskey-hub.net/ns#", ...QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone/notes/123", summary: "Hello, world!", type: "Note" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleObject(context.request, { context, values: { identifier: "no-one", id: "123" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleObject(context.request, { context, values: { identifier: "someone", id: "not-exist" }, objectDispatcher, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleObject(context.request, { context, values: { identifier: "someone", id: "123" }, objectDispatcher, authorizePredicate: async (ctx, _values) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null, onNotFound, onUnauthorized }); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext({ ...context, getSignedKey: () => Promise.resolve(rsaPublicKey2), getSignedKeyOwner: () => Promise.resolve(new Person({})) }); response = await handleObject(context.request, { context, values: { identifier: "someone", id: "123" }, objectDispatcher, authorizePredicate: async (ctx, _values) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", toot: "http://joinmastodon.org/ns#", _misskey_quote: "misskey:_misskey_quote", fedibird: "http://fedibird.com/ns#", misskey: "https://misskey-hub.net/ns#", ...QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone/notes/123", summary: "Hello, world!", type: "Note" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); }); test("handleCollection()", async () => { let context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore() }), data: void 0, url: new URL("https://example.com/"), getActorUri(identifier) { return new URL(`https://example.com/users/${identifier}`); } }); const dispatcher = (_ctx, identifier, cursor) => { if (identifier !== "someone") return null; const items = [ new Create({ id: new URL("https://example.com/activities/1") }), new Create({ id: new URL("https://example.com/activities/2") }), new Create({ id: new URL("https://example.com/activities/3") }) ]; if (cursor != null) { const idx = parseInt(cursor); return { items: [items[idx]], nextCursor: idx < items.length - 1 ? (idx + 1).toString() : null, prevCursor: idx > 0 ? (idx - 1).toString() : null }; } return { items }; }; const counter = (_ctx, identifier) => identifier === "someone" ? 3 : null; const firstCursor = (_ctx, identifier) => identifier === "someone" ? "0" : null; const lastCursor = (_ctx, identifier) => identifier === "someone" ? "2" : null; let onNotFoundCalled = null; const onNotFound = (request) => { onNotFoundCalled = request; return new Response("Not found", { status: 404 }); }; let onUnauthorizedCalled = null; const onUnauthorized = (request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection(context.request, { context, name: "collection", identifier: "no-one", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext({ ...context, request: new Request(context.url, { headers: { Accept: "application/activity+json" } }) }); response = await handleCollection(context.request, { context, name: "collection", identifier: "no-one", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, onUnauthorized }); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); const createCtx = [ "https://w3id.org/identity/v1", "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: { "@id": "toot:votersCount", "@type": "http://www.w3.org/2001/XMLSchema#nonNegativeInteger" }, _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ]; assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: "toot:votersCount", _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone", type: "OrderedCollection", orderedItems: [ { "@context": createCtx, type: "Create", id: "https://example.com/activities/1" }, { "@context": createCtx, type: "Create", id: "https://example.com/activities/2" }, { "@context": createCtx, type: "Create", id: "https://example.com/activities/3" } ] }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, authorizePredicate: async (ctx, _handle) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null }, onNotFound, onUnauthorized }); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext({ ...context, getSignedKey: () => Promise.resolve(rsaPublicKey2), getSignedKeyOwner: () => Promise.resolve(new Person({})) }); response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, authorizePredicate: async (ctx, _handle) => await ctx.getSignedKey() != null && await ctx.getSignedKeyOwner() != null }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: "toot:votersCount", _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone", type: "OrderedCollection", orderedItems: [ { "@context": createCtx, type: "Create", id: "https://example.com/activities/1" }, { "@context": createCtx, type: "Create", id: "https://example.com/activities/2" }, { "@context": createCtx, type: "Create", id: "https://example.com/activities/3" } ] }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, counter, firstCursor, lastCursor }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: "toot:votersCount", _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone", type: "OrderedCollection", totalItems: 3, first: "https://example.com/?cursor=0", last: "https://example.com/?cursor=2" }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); let url = new URL("https://example.com/?cursor=0"); context = createRequestContext({ ...context, url, request: new Request(url, { headers: { Accept: "application/activity+json" } }) }); response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, counter, firstCursor, lastCursor }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: "toot:votersCount", _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone?cursor=0", type: "OrderedCollectionPage", partOf: "https://example.com/", next: "https://example.com/?cursor=1", orderedItems: [{ "@context": createCtx, id: "https://example.com/activities/1", type: "Create" }] }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); url = new URL("https://example.com/?cursor=2"); context = createRequestContext({ ...context, url, request: new Request(url, { headers: { Accept: "application/activity+json" } }) }); response = await handleCollection(context.request, { context, name: "collection", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, counter, firstCursor, lastCursor }, onNotFound, onUnauthorized }); assertEquals(response.status, 200); assertEquals(response.headers.get("Content-Type"), "application/activity+json"); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", "https://gotosocial.org/ns", { toot: "http://joinmastodon.org/ns#", misskey: "https://misskey-hub.net/ns#", fedibird: "http://fedibird.com/ns#", ChatMessage: "http://litepub.social/ns#ChatMessage", Emoji: "toot:Emoji", Hashtag: "as:Hashtag", sensitive: "as:sensitive", votersCount: "toot:votersCount", _misskey_quote: "misskey:_misskey_quote", ...WRAPPER_QUOTE_CONTEXT_TERMS, quoteUri: "fedibird:quoteUri", quoteUrl: "as:quoteUrl", emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" } } ], id: "https://example.com/users/someone?cursor=2", type: "OrderedCollectionPage", partOf: "https://example.com/", prev: "https://example.com/?cursor=1", orderedItems: [{ "@context": createCtx, id: "https://example.com/activities/3", type: "Create" }] }); assertEquals(onNotFoundCalled, null); assertEquals(onUnauthorizedCalled, null); }); test("handleCollection() records OpenTelemetry collection metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore(), meterProvider }), data: void 0, url: new URL("https://example.com/users/someone/followers"), request: new Request("https://example.com/users/someone/followers", { headers: { Accept: "application/activity+json" } }), getActorUri(identifier) { return new URL(`https://example.com/users/${identifier}`); } }); const dispatcher = (_ctx, identifier) => identifier === "someone" ? { items: [ new Create({ id: new URL("https://example.com/activities/1") }), new Create({ id: new URL("https://example.com/activities/2") }), new Create({ id: new URL("https://example.com/activities/3") }) ] } : null; const counter = (_ctx, identifier) => identifier === "someone" ? 3 : null; assertEquals((await handleCollection(context.request, { context, name: "followers", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}/followers`); }, collectionCallbacks: { dispatcher, counter }, meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }) })).status, 200); const requests = recorder.getMeasurements("activitypub.collection.request"); assertEquals(requests.length, 1); assertEquals(requests[0].type, "counter"); assertEquals(requests[0].value, 1); assertEquals(requests[0].attributes["activitypub.collection.kind"], "followers"); assertEquals(requests[0].attributes["activitypub.collection.page"], false); assertEquals(requests[0].attributes["fedify.collection.dispatcher"], "built_in"); assertEquals(requests[0].attributes["activitypub.collection.result"], "served"); assertEquals(requests[0].attributes["http.response.status_code"], 200); assertEquals("activitypub.collection.id" in requests[0].attributes, false); const durations = recorder.getMeasurements("activitypub.collection.dispatch.duration"); assertEquals(durations.length, 1); assertEquals(durations[0].type, "histogram"); assert(durations[0].value >= 0); assertEquals(durations[0].attributes["activitypub.collection.result"], "served"); const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); assertEquals(items[0].type, "histogram"); assertEquals(items[0].value, 3); assertEquals(items[0].attributes["activitypub.collection.page"], false); const totalItems = recorder.getMeasurements("activitypub.collection.total_items"); assertEquals(totalItems.length, 1); assertEquals(totalItems[0].type, "histogram"); assertEquals(totalItems[0].value, 3); }); test("handleCollection() classifies camelCase collection metric names", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore(), meterProvider }), data: void 0, url: new URL("https://example.com/users/someone/collections/featured"), request: new Request("https://example.com/users/someone/collections/featured", { headers: { Accept: "application/activity+json" } }) }); const dispatcher = () => ({ items: [] }); assertEquals((await handleCollection(context.request, { context, name: "featuredTags", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}/collections/featured`); }, collectionCallbacks: { dispatcher }, meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }) })).status, 200); const requests = recorder.getMeasurements("activitypub.collection.request"); assertEquals(requests.length, 1); assertEquals(requests[0].attributes["activitypub.collection.kind"], "featured_tags"); }); test("handleCollection() records filtered collection item metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore(), meterProvider }), data: void 0, url: new URL("https://example.com/users/someone/followers"), request: new Request("https://example.com/users/someone/followers", { headers: { Accept: "application/activity+json" } }) }); const dispatcher = () => ({ items: [new Create({ id: new URL("https://example.com/activities/1") }), new Create({ id: new URL("https://example.com/activities/2") })] }); assertEquals((await handleCollection(context.request, { context, name: "followers", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}/followers`); }, collectionCallbacks: { dispatcher }, filterPredicate: (item) => item.id?.href === "https://example.com/activities/1", meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }) })).status, 200); const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); assertEquals(items[0].value, 1); assertEquals(items[0].attributes["activitypub.collection.page"], false); assertEquals(items[0].attributes["activitypub.collection.result"], "served"); assertEquals(items[0].attributes["http.response.status_code"], 200); }); test("handleCollection() records filtered collection page item metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore(), meterProvider }), data: void 0, url: new URL("https://example.com/users/someone/followers?cursor=2"), request: new Request("https://example.com/users/someone/followers?cursor=2", { headers: { Accept: "application/activity+json" } }) }); const dispatcher = () => ({ items: [new Create({ id: new URL("https://example.com/activities/1") }), new Create({ id: new URL("https://example.com/activities/2") })] }); assertEquals((await handleCollection(context.request, { context, name: "followers", identifier: "someone", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}/followers`); }, collectionCallbacks: { dispatcher }, filterPredicate: (item) => item.id?.href === "https://example.com/activities/1", meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }) })).status, 200); const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); assertEquals(items[0].value, 1); assertEquals(items[0].attributes["activitypub.collection.page"], true); assertEquals(items[0].attributes["activitypub.collection.result"], "served"); assertEquals(items[0].attributes["http.response.status_code"], 200); }); test("handleCollection() records not_found collection metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const context = createRequestContext({ federation: createFederation({ kv: new MemoryKvStore(), meterProvider }), data: void 0, url: new URL("https://example.com/users/nobody/outbox"), request: new Request("https://example.com/users/nobody/outbox", { headers: { Accept: "application/activity+json" } }) }); const dispatcher = () => null; assertEquals((await handleCollection(context.request, { context, name: "outbox", identifier: "nobody", uriGetter(identifier) { return new URL(`https://example.com/users/${identifier}/outbox`); }, collectionCallbacks: { dispatcher }, meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }) })).status, 404); const requests = recorder.getMeasurements("activitypub.collection.request"); assertEquals(requests.length, 1); assertEquals(requests[0].attributes["activitypub.collection.kind"], "outbox"); assertEquals(requests[0].attributes["activitypub.collection.result"], "not_found"); assertEquals(requests[0].attributes["http.response.status_code"], 404); const durations = recorder.getMeasurements("activitypub.collection.dispatch.duration"); assertEquals(durations.length, 1); assertEquals(durations[0].attributes["activitypub.collection.result"], "not_found"); assertEquals(recorder.getMeasurements("activitypub.collection.page.items").length, 0); }); test("handleInbox()", async () => { const activity = new Create({ id: new URL("https://example.com/activities/1"), actor: new URL("https://example.com/person2"), object: new Note({ id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/person2"), content: "Hello, world!" }) }); const unsignedRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(await activity.toJsonLd()) }); const federation = createFederation({ kv: new MemoryKvStore() }); const unsignedContext = createRequestContext({ federation, request: unsignedRequest, url: new URL(unsignedRequest.url), data: void 0 }); let onNotFoundCalled = null; const onNotFound = (request) => { onNotFoundCalled = request; return new Response("Not found", { status: 404 }); }; const actorDispatcher = (_ctx, identifier) => { if (identifier !== "someone") return null; return new Person({ name: "Someone" }); }; const restrictiveContextLoader = async (resource) => { const url = new URL(resource).href; if (url === "https://www.w3.org/ns/activitystreams" || url === "https://w3id.org/identity/v1") return await mockDocumentLoader(url); throw new Error(`Unexpected context: ${url}`); }; const inboxOptions = { kv: new MemoryKvStore(), kvPrefixes: { activityIdempotence: ["_fedify", "activityIdempotence"], publicKey: ["_fedify", "publicKey"], acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"] }, actorDispatcher, onNotFound, signatureTimeWindow: { minutes: 5 }, skipSignatureVerification: false }; let response = await handleInbox(unsignedRequest, { recipient: null, context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...unsignedContext, clone: void 0 }); }, ...inboxOptions, actorDispatcher: void 0 }); assertEquals(onNotFoundCalled, unsignedRequest); assertEquals(response.status, 404); onNotFoundCalled = null; response = await handleInbox(unsignedRequest, { recipient: "nobody", context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...unsignedContext, clone: void 0, recipient: "nobody" }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, unsignedRequest); assertEquals(response.status, 404); onNotFoundCalled = null; response = await handleInbox(unsignedRequest, { recipient: null, context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...unsignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, null); assertEquals(response.status, 401); response = await handleInbox(unsignedRequest, { recipient: "someone", context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...unsignedContext, clone: void 0, recipient: "someone" }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, null); assertEquals(response.status, 401); const malformedProofCreatedRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify({ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1"], id: "https://example.com/activities/invalid-proof-created", type: "Create", actor: "https://example.com/person2", object: { id: "https://example.com/notes/invalid-proof-created", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" }, proof: { type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022", verificationMethod: "https://example.com/person2#main-key", proofPurpose: "assertionMethod", created: { "@value": "not-a-date" }, proofValue: "zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z" } }) }); const malformedProofCreatedContext = createRequestContext({ federation, request: malformedProofCreatedRequest, url: new URL(malformedProofCreatedRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader }); response = await handleInbox(malformedProofCreatedRequest, { recipient: null, context: malformedProofCreatedContext, inboxContextFactory(_activity) { return createInboxContext({ ...malformedProofCreatedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals([response.status, await response.text()], [400, "Invalid activity."]); onNotFoundCalled = null; const signedRequest = await signRequest(unsignedRequest.clone(), rsaPrivateKey3, rsaPublicKey3.id); const signedContext = createRequestContext({ federation, request: signedRequest, url: new URL(signedRequest.url), data: void 0, documentLoader: mockDocumentLoader }); response = await handleInbox(signedRequest, { recipient: null, context: signedContext, inboxContextFactory(_activity) { return createInboxContext({ ...unsignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, null); assertEquals([response.status, await response.text()], [202, ""]); const ldSignedRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(await signJsonLd({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/identity/v1", "https://w3id.org/security/v1", "https://w3id.org/security/data-integrity/v1" ], id: "https://example.com/activities/ld-signed", type: "Create", actor: "https://example.com/person2", object: { id: "https://example.com/notes/ld-signed", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" } }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader })) }); const ldSignedContext = createRequestContext({ federation, request: ldSignedRequest, url: new URL(ldSignedRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: restrictiveContextLoader }); response = await handleInbox(ldSignedRequest, { recipient: null, context: ldSignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...ldSignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, null); assertEquals([response.status, await response.text()], [202, ""]); const remoteContextUrl = "https://remote.example/contexts/ext"; let failRemoteContextOnce = true; const flakyContextLoader = async (resource) => { const url = new URL(resource).href; if (url === remoteContextUrl) { if (failRemoteContextOnce) { failRemoteContextOnce = false; throw new Error(`Unexpected context: ${url}`); } return { contextUrl: null, documentUrl: url, document: { "@context": { ext: "https://example.com/ext" } } }; } return await mockDocumentLoader(url); }; const httpSignedLdBody = { "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"], id: "https://example.com/activities/http-signed-ld", type: "Create", actor: "https://example.com/person2", ext: "preserve-me", object: { id: "https://example.com/notes/http-signed-ld", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" }, signature: { type: "RsaSignature2017", creator: rsaPublicKey3.id.href, created: "2024-01-01T00:00:00Z", signatureValue: "bogus" } }; const httpSignedLdRequest = await signRequest(new Request("https://example.com/", { method: "POST", body: JSON.stringify(httpSignedLdBody) }), rsaPrivateKey3, rsaPublicKey3.id); const httpSignedLdContext = createRequestContext({ federation, request: httpSignedLdRequest, url: new URL(httpSignedLdRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: flakyContextLoader }); response = await handleInbox(httpSignedLdRequest, { recipient: null, context: httpSignedLdContext, inboxContextFactory(_activity) { return createInboxContext({ ...httpSignedLdContext, clone: void 0 }); }, ...inboxOptions }); assertEquals(onNotFoundCalled, null); assertEquals([response.status, await response.text()], [202, ""]); const ldSignedOnlyBody = await signJsonLd({ "@context": [remoteContextUrl, "https://www.w3.org/ns/activitystreams"], id: "https://example.com/activities/ld-only-transient", type: "Create", actor: "https://example.com/person2", ext: "preserve-me", object: { id: "https://example.com/notes/ld-only-transient", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" } }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: async (resource) => { const url = new URL(resource).href; if (url === remoteContextUrl) return { contextUrl: null, documentUrl: url, document: { "@context": { ext: "https://example.com/ext" } } }; return await mockDocumentLoader(url); } }); const malformedTemporalLdSignedBody = await signJsonLd({ "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/activities/ld-only-invalid-published", type: "Create", actor: "https://example.com/person2", published: { "@value": "not-a-date" }, object: { id: "https://example.com/notes/ld-only-invalid-published", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" } }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader }); const malformedTemporalLdSignedRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(malformedTemporalLdSignedBody) }); const malformedTemporalLdSignedContext = createRequestContext({ federation, request: malformedTemporalLdSignedRequest, url: new URL(malformedTemporalLdSignedRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader }); response = await handleInbox(malformedTemporalLdSignedRequest, { recipient: null, context: malformedTemporalLdSignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...malformedTemporalLdSignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals([response.status, await response.text()], [400, "Invalid activity."]); const malformedClosedLdSignedBody = await signJsonLd({ "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/questions/ld-only-invalid-closed", type: "Question", closed: "2024-02-31T00:00:00Z" }, rsaPrivateKey3, rsaPublicKey3.id, { contextLoader: mockDocumentLoader }); const malformedClosedLdSignedRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(malformedClosedLdSignedBody) }); const malformedClosedLdSignedContext = createRequestContext({ federation, request: malformedClosedLdSignedRequest, url: new URL(malformedClosedLdSignedRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader }); response = await handleInbox(malformedClosedLdSignedRequest, { recipient: null, context: malformedClosedLdSignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...malformedClosedLdSignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals([response.status, await response.text()], [400, "Invalid activity."]); const malformedIriHttpSignedRequest = await signRequest(new Request("https://example.com/", { method: "POST", body: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", id: "http://[", type: "Create", actor: "https://example.com/person2", object: { id: "https://example.com/notes/http-signed-invalid-iri", type: "Note", attributedTo: "https://example.com/person2", content: "Hello, world!" } }) }), rsaPrivateKey3, rsaPublicKey3.id); const malformedIriHttpSignedContext = createRequestContext({ federation, request: malformedIriHttpSignedRequest, url: new URL(malformedIriHttpSignedRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader }); response = await handleInbox(malformedIriHttpSignedRequest, { recipient: null, context: malformedIriHttpSignedContext, inboxContextFactory(_activity) { return createInboxContext({ ...malformedIriHttpSignedContext, clone: void 0 }); }, ...inboxOptions }); assertEquals([response.status, await response.text()], [400, "Invalid activity."]); const ldSignedOnlyRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(ldSignedOnlyBody) }); const ldSignedOnlyContext = createRequestContext({ federation, request: ldSignedOnlyRequest, url: new URL(ldSignedOnlyRequest.url), data: void 0, documentLoader: mockDocumentLoader, contextLoader: async (resource) => { const url = new URL(resource).href; if (url === remoteContextUrl) throw new Error(`Unexpected context: ${url}`); return await mockDocumentLoader(url); } }); await assertRejects(() => handleInbox(ldSignedOnlyRequest, { recipient: null, context: ldSignedOnlyContext, inboxContextFactory(_activity) { return createInboxContext({ ...ldSignedOnlyContext, clone: void 0 }); }, ...inboxOptions }), Error); failRemoteContextOnce = true; const invalidHttpFallbackRequest = new Request("https://example.com/", { method: "POST", body: JSON.stringify(ldSig