From 6d76bf99a32cebe4ed468c0dc0c77ce3b062bae1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 5 Oct 2026 23:31:16 +0900 Subject: [PATCH 1/2] Report transient interaction control failures The interaction control helpers hid why a verification failed: fetch failures of a request's object or instrument looked like requests that lacked them, failed JSON-LD context loads looked like malformed documents, and errors from matchesApprovalCollection became denials. An inbox listener therefore could not tell whether to retry later or to reject the request for good, and Hollo, BotKit, and Hackers' Pub all had to work around the helpers. This commit includes the following changes: - verifyRequest() reports fetch failures of the object or instrument as notDereferenceable with the failed URL and the loader's error as cause, and failed context loads as notDereferenceable with the context URL. Loader errors are tracked per dereferencing operation so that recovered attempts and parse errors are not mistaken for fetch failures. - Unverifiable failures of verifyRequest() and verifyAuthorization() have a transient flag, computed by the new isTransientFetchError() in @fedify/vocab-runtime. - evaluatePolicy() keeps the collection callback's error as cause, and takes collectionErrors, fallbackRule, and precedence options. - verifyRequest() takes contextLoader and already resolved objects; quoteInteraction.verifyRequest() takes options for quote posts whose quote and quoteUrl disagree, that have several attributions, or that have no attribution. - verifyAuthorization() takes contextLoader, authorizationId, and allowOffOrigin options. - The id and to options of the Accept, Reject, and Delete constructors are optional, and createRevocation() can embed the authorization with only the IDs of its interacting object and target. All defaults keep the 2.4 behavior, except that fetch and context load failures are now reported as such. Preserving the sender's exact request representation when the helper dereferences it is out of scope; the manual suggests cloning the request before verification. Closes https://github.com/fedify-dev/fedify/issues/1206 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: OpenCode:deepseek-flash Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01MTz8EqRkcCsxbf9B7Wf95U --- CHANGES.md | 61 ++ .../verification-failures.md | 56 + .../vocab-runtime/transient-fetch-errors.md | 9 + docs/manual/interaction-controls.md | 232 ++++- .../interaction-controls/src/control.test.ts | 979 ++++++++++++++++++ packages/interaction-controls/src/control.ts | 591 ++++++++--- .../interaction-controls/src/like.test.ts | 17 +- .../interaction-controls/src/quote.test.ts | 345 +++++- packages/interaction-controls/src/quote.ts | 34 +- packages/interaction-controls/src/types.ts | 229 +++- packages/vocab-runtime/src/mod.ts | 1 + packages/vocab-runtime/src/request.test.ts | 144 ++- packages/vocab-runtime/src/request.ts | 113 ++ 13 files changed, 2642 insertions(+), 169 deletions(-) create mode 100644 changes.d/interaction-controls/verification-failures.md create mode 100644 changes.d/vocab-runtime/transient-fetch-errors.md create mode 100644 packages/interaction-controls/src/control.test.ts diff --git a/CHANGES.md b/CHANGES.md index 4d3a4f422..320253e05 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,8 +32,69 @@ To be released. [#896]: https://github.com/fedify-dev/fedify/issues/896 [#1204]: https://github.com/fedify-dev/fedify/issues/1204 +### @fedify/interaction-controls + + - Changed the interaction control helpers to tell transient verification + failures from rejections, so that inbox listeners can retry instead of + rejecting a valid request. [[#1206], [#1245]] + + - `verifyRequest()` now reports a failed fetch of the request's `object` + or `instrument` as `notDereferenceable` with the failed URL and the + loader's error as `cause`, instead of `missingObject` or + `missingInstrument`. Those two failures now mean that the request + really lacks the property. + - A remote JSON-LD context that cannot be loaded is now reported as + `notDereferenceable` with the context's URL instead of + `invalidJsonLd`. + - Added a `transient` property to `unverifiable` failures of + `verifyRequest()` and `verifyAuthorization()`. It is `true` for + network errors, timeouts, DNS failures, and HTTP 5xx, 408, and 429 + responses. + - The `unverifiableCollection` denial reason of `evaluatePolicy()` now + has the error thrown by `matchesApprovalCollection` as its `cause`. + Added a `collectionErrors` option; pass `"throw"` to let the error + propagate instead of denying the interaction. + + - Added options to the interaction control helpers so that applications + with their own compatibility rules can use them without adapters. + [[#1206], [#1245]] + + - Added `fallbackRule` and `precedence` options to `evaluatePolicy()`. + `fallbackRule` is evaluated when the subject has no rule for the + interaction, and `precedence: "automatic"` matches every + `automaticApproval` entry before any `manualApproval` entry. + - Added `resolvedInteractionTarget` and `resolvedInteractingObject` + options to `verifyRequest()`, which take already resolved objects + instead of dereferencing the request's `object` and `instrument`, and + leave the request untouched. + - Added `quoteReference`, `attribution`, and `missingAttribution` + options to `quoteInteraction.verifyRequest()` for quote posts whose + `quote` and `quoteUrl` disagree, that have several attributions, or + that have no attribution. The new `QuoteRequestValidationOptions` + type describes them. + - Added `authorizationId` and `allowOffOrigin` options to + `verifyAuthorization()`. `authorizationId` checks the ID of an + authorization given as an object, and `allowOffOrigin` lets + `verifyAuthenticity` approve an authorization whose ID is on another + origin than its attributed actor. + - Added a `contextLoader` option to `verifyRequest()` and + `verifyAuthorization()` for loading remote JSON-LD contexts + separately from objects. + - The `id` and `to` options of `createAccept()`, `createReject()`, and + `createRevocation()` are now optional. + - Added an `embedAuthorization` option to `createRevocation()`, which + embeds the authorization in the `Delete` activity with only the IDs of + its interacting object and interaction target. + +[#1206]: https://github.com/fedify-dev/fedify/issues/1206 +[#1245]: https://github.com/fedify-dev/fedify/pull/1245 + ### @fedify/vocab-runtime + - Added `isTransientFetchError()` function, which tells whether an error + thrown by a document loader is likely transient, such as a network error, + a timeout, a DNS failure, or an HTTP 5xx, 408, or 429 response, so that + applications can decide whether to retry. [[#1206], [#1245]] - Added `normalizeLanguageTag()` function, which replaces a language tag that has an extended language subtag (extlang) with its canonical form, e.g., `zh-YUE` with `yue`. `Intl.Locale` rejects extlang tags, so diff --git a/changes.d/interaction-controls/verification-failures.md b/changes.d/interaction-controls/verification-failures.md new file mode 100644 index 000000000..54989a710 --- /dev/null +++ b/changes.d/interaction-controls/verification-failures.md @@ -0,0 +1,56 @@ +--- +links: + '#1206': https://github.com/fedify-dev/fedify/issues/1206 + '#1245': https://github.com/fedify-dev/fedify/pull/1245 +--- + - Changed the interaction control helpers to tell transient verification + failures from rejections, so that inbox listeners can retry instead of + rejecting a valid request. [[#1206], [#1245]] + + - `verifyRequest()` now reports a failed fetch of the request's `object` + or `instrument` as `notDereferenceable` with the failed URL and the + loader's error as `cause`, instead of `missingObject` or + `missingInstrument`. Those two failures now mean that the request + really lacks the property. + - A remote JSON-LD context that cannot be loaded is now reported as + `notDereferenceable` with the context's URL instead of + `invalidJsonLd`. + - Added a `transient` property to `unverifiable` failures of + `verifyRequest()` and `verifyAuthorization()`. It is `true` for + network errors, timeouts, DNS failures, and HTTP 5xx, 408, and 429 + responses. + - The `unverifiableCollection` denial reason of `evaluatePolicy()` now + has the error thrown by `matchesApprovalCollection` as its `cause`. + Added a `collectionErrors` option; pass `"throw"` to let the error + propagate instead of denying the interaction. + + - Added options to the interaction control helpers so that applications + with their own compatibility rules can use them without adapters. + [[#1206], [#1245]] + + - Added `fallbackRule` and `precedence` options to `evaluatePolicy()`. + `fallbackRule` is evaluated when the subject has no rule for the + interaction, and `precedence: "automatic"` matches every + `automaticApproval` entry before any `manualApproval` entry. + - Added `resolvedInteractionTarget` and `resolvedInteractingObject` + options to `verifyRequest()`, which take already resolved objects + instead of dereferencing the request's `object` and `instrument`, and + leave the request untouched. + - Added `quoteReference`, `attribution`, and `missingAttribution` + options to `quoteInteraction.verifyRequest()` for quote posts whose + `quote` and `quoteUrl` disagree, that have several attributions, or + that have no attribution. The new `QuoteRequestValidationOptions` + type describes them. + - Added `authorizationId` and `allowOffOrigin` options to + `verifyAuthorization()`. `authorizationId` checks the ID of an + authorization given as an object, and `allowOffOrigin` lets + `verifyAuthenticity` approve an authorization whose ID is on another + origin than its attributed actor. + - Added a `contextLoader` option to `verifyRequest()` and + `verifyAuthorization()` for loading remote JSON-LD contexts + separately from objects. + - The `id` and `to` options of `createAccept()`, `createReject()`, and + `createRevocation()` are now optional. + - Added an `embedAuthorization` option to `createRevocation()`, which + embeds the authorization in the `Delete` activity with only the IDs of + its interacting object and interaction target. diff --git a/changes.d/vocab-runtime/transient-fetch-errors.md b/changes.d/vocab-runtime/transient-fetch-errors.md new file mode 100644 index 000000000..ad6110b4c --- /dev/null +++ b/changes.d/vocab-runtime/transient-fetch-errors.md @@ -0,0 +1,9 @@ +--- +links: + '#1206': https://github.com/fedify-dev/fedify/issues/1206 + '#1245': https://github.com/fedify-dev/fedify/pull/1245 +--- + - Added `isTransientFetchError()` function, which tells whether an error + thrown by a document loader is likely transient, such as a network error, + a timeout, a DNS failure, or an HTTP 5xx, 408, or 429 response, so that + applications can decide whether to retry. [[#1206], [#1245]] diff --git a/docs/manual/interaction-controls.md b/docs/manual/interaction-controls.md index 5f8a37f5e..4a05c4fc3 100644 --- a/docs/manual/interaction-controls.md +++ b/docs/manual/interaction-controls.md @@ -100,6 +100,75 @@ permissively for legacy-compatible interactions: - The feature helper treats missing `canFeature` policy as denied, because featuring another actor is a profile/discovery action. +### Fallback rules + +*This API is available since Fedify 2.5.0.* + +If your application has its own default policy, pass it as `fallbackRule`. +It is evaluated in place of the subject's rule when the subject has no +interaction policy, no rule for the interaction, or a rule without any +approval entries: + +~~~~ typescript twoslash +import { quoteInteraction } from "@fedify/interaction-controls"; +import { InteractionRule, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; +import type { Context } from "@fedify/fedify"; + +const context = {} as Context; +const target = new Note({}); +const requester = new URL("https://remote.example/users/bob"); +// ---cut-before--- +const decision = await quoteInteraction.evaluatePolicy(context, { + subject: target, + requester, + // Quotes are public by default in this application: + fallbackRule: new InteractionRule({ automaticApproval: PUBLIC_COLLECTION }), +}); +~~~~ + +### Precedence + +*This API is available since Fedify 2.5.0.* + +By default, actors listed explicitly in `automaticApproval` or +`manualApproval` are matched before the public collection and other +collections. So an actor listed in `manualApproval` gets a manual decision +even when `automaticApproval` contains the public collection. Pass +`precedence: "automatic"` to match every `automaticApproval` entry before any +`manualApproval` entry instead. + +### Approval collections + +To match collections such as the owner's followers, pass +a `matchesApprovalCollection` callback, which usually queries your database. +By default, an error thrown by the callback makes that collection count as not +matching; if nothing else decides, the decision is `denied` with an +`unverifiableCollection` reason whose `cause` is the error. + +Sending a `Reject` for such a decision would refuse a request only because of +a temporary database failure. *Since Fedify 2.5.0*, you can pass +`collectionErrors: "throw"` to let the first error propagate instead, e.g., so +that the inbox listener fails and the activity is retried later: + +~~~~ typescript twoslash +import { likeInteraction } from "@fedify/interaction-controls"; +import { Note } from "@fedify/vocab"; +import type { Context } from "@fedify/fedify"; + +const context = {} as Context; +const target = new Note({}); +const requester = new URL("https://remote.example/users/bob"); +declare function isFollower(collection: URL, actor: URL): Promise; +// ---cut-before--- +const decision = await likeInteraction.evaluatePolicy(context, { + subject: target, + requester, + matchesApprovalCollection: (collection, actor) => + isFollower(collection, actor), + collectionErrors: "throw", +}); +~~~~ + End-to-end flow --------------- @@ -196,6 +265,73 @@ request `instrument` has this meaning: - Feature: the `FeaturedCollection` owned by the requester. The request `object` is the actor being featured. +`verifyRequest()` dereferences the request's `object` and `instrument` when +they are given as IRIs, and caches the dereferenced objects in the request +object, as other property accessors do. If you need to send the request back +in an `Accept` or `Reject` with its references intact, clone it before +verification and pass the clone to `createAccept()` or `createReject()`. +The clone keeps the request's vocabulary values and references, though not +necessarily its exact original JSON-LD representation. + +### Already resolved objects + +*This API is available since Fedify 2.5.0.* + +If your application has already resolved the request's `object` or +`instrument`, for example by looking up a local post in its database, pass +them as `resolvedInteractionTarget` and `resolvedInteractingObject`. +The helper then uses them instead of dereferencing the references, and leaves +the request untouched: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import { quoteInteraction } from "@fedify/interaction-controls"; +import { Note, QuoteRequest } from "@fedify/vocab"; + +const context = {} as Context; +const request = null as unknown as QuoteRequest; +declare function findLocalPost(id: URL): Promise; +declare function fetchQuotePost(id: URL): Promise; +// ---cut-before--- +const verified = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: await findLocalPost(request.objectId!), + resolvedInteractingObject: await fetchQuotePost(request.instrumentId!), +}); +~~~~ + +A resolved object is trusted as the resolution of the request's reference. +Its ID does not have to match the reference, and the helper does not check +where it came from, so make sure it is trustworthy, e.g., that the instrument +was fetched from the requester's origin. The helper still checks its type, +its requester, and that it refers to the target. Resolved objects are used +only when the request has the corresponding reference; they never fill in +a missing `object` or `instrument`. + +### Relaxing quote request validation + +*This API is available since Fedify 2.5.0.* + +By default, `quoteInteraction` requires the quote post's `quote` and +`quoteUrl` to agree with each other and with the target, and its first +`attributedTo` to be the requester. Some servers send quote posts that do not +meet these checks, so `quoteInteraction.verifyRequest()` takes options to +relax them: + +`quoteReference` +: `"preferQuote"` checks `quote` and ignores a conflicting `quoteUrl`. + `"any"` accepts the request if either `quote` or `quoteUrl` refers to the + target. The default is `"strict"`. + +`attribution` +: `"any"` accepts the requester anywhere in `attributedTo`. The default is + `"first"`. + +`missingAttribution` +: `"requester"` treats a quote post without any attribution IRI as + attributed to the requester. An attribution embedded without an `id` + counts as missing. The default is `"reject"`. + Authorization flow ------------------ @@ -241,9 +377,9 @@ const context = {} as Context; const authorization = null as unknown as LikeAuthorization; const like = null as unknown as Like; const target = null as unknown as Note; -const verifyEmbeddedAuthorization = async ( +declare function isStoredAuthorization( authorization: LikeAuthorization, -) => authorization.id?.origin === "https://example.com"; +): Promise; // ---cut-before--- const verified = await likeInteraction.verifyAuthorization(context, { @@ -251,17 +387,107 @@ const verified = await likeInteraction.verifyAuthorization(context, { interactingObject: like, interactionTarget: target, attributedTo: target.attributionId ?? undefined, - verifyAuthenticity: verifyEmbeddedAuthorization, + // E.g., check that the authorization matches one stored when its signed + // `Accept` was received from the target's owner: + verifyAuthenticity: isStoredAuthorization, }); if (!verified.verified) { throw new Error(`Invalid authorization: ${verified.failure.type}`); } ~~~~ +An origin check alone does not establish authenticity, since anyone can embed +an object claiming any ID. Base `verifyAuthenticity` on actual evidence, such +as a signed `Accept` from the target's owner or a grant stored in your +database. + +*Since Fedify 2.5.0*, the following options are also available: + +`authorizationId` +: The expected ID of the authorization. An authorization object with + a different ID fails with `idMismatch`. This only checks identity; an + authorization object still needs `verifyAuthenticity`. + +`allowOffOrigin` +: Accepts an authorization whose ID is on a different origin than the + target's owner, if `verifyAuthenticity` approves it. Without + `verifyAuthenticity`, such authorizations still fail with + `originMismatch`. + +`contextLoader` +: A separate document loader for remote JSON-LD contexts. `verifyRequest()` + takes it too. + You can also create `Accept`, `Reject`, and revocation activities from the same helper. Store authorization IDs with the interaction object so that later revocation checks can reject stale approvals. +The `id` and `to` options of `createAccept()`, `createReject()`, and +`createRevocation()` are optional *since Fedify 2.5.0*, since +`Context.sendActivity()` assigns an ID when it is missing and takes the +recipients separately. `createRevocation()` refers to the authorization by its +ID unless you pass `embedAuthorization: true` with an authorization object, in +which case it embeds a copy that contains only the authorization's ID, +attribution, and the IDs of its interacting object and interaction target. + + +Handling verification failures +------------------------------ + +When verification fails, `failure.category` tells what kind of failure it is: + +`"invalid"` +: The request is malformed, e.g., it lacks an `object` or refers to + a different target than its instrument. + +`"unauthorized"` +: The request or authorization is well-formed but does not grant the + interaction, e.g., the requester is not the instrument's author. + +`"revoked"` +: The authorization has been revoked. + +`"unverifiable"` +: The helper could not get the documents it needs to decide, e.g., because + fetching one failed. + +An inbox listener usually rejects or ignores the first three, but an +unverifiable failure may be temporary. *Since Fedify 2.5.0*, unverifiable +failures have a `transient` flag that tells whether verifying again later could +succeed: network errors, timeouts, DNS failures, and HTTP 5xx, 408, and 429 +responses are transient, while HTTP 404 and other 4xx responses, URLs blocked +by SSRF protection, and malformed documents are not. The same classification +is available for any document loader error as `isTransientFetchError()` from +`@fedify/vocab-runtime`. + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import { quoteInteraction } from "@fedify/interaction-controls"; +import { QuoteRequest } from "@fedify/vocab"; + +const context = {} as Context; +const request = null as unknown as QuoteRequest; +// ---cut-before--- +const verified = await quoteInteraction.verifyRequest(context, { request }); +if (!verified.verified) { + if ( + verified.failure.category === "unverifiable" && verified.failure.transient + ) { + // Throwing from an inbox listener makes Fedify retry the activity later: + throw new Error("Failed to verify the quote request; retrying later.", { + cause: verified.failure, + }); + } + // Otherwise, reject the request. +} +~~~~ + +When fetching the request's `object` or `instrument` fails, the failure is +`notDereferenceable` with the URL that failed and the loader's error as +`cause`; `missingObject` and `missingInstrument` mean that the request really +lacks them. When a remote JSON-LD context cannot be loaded, the failure is +also `notDereferenceable`, with the context's URL, rather than `invalidJsonLd`. + Recognizing unrequested interactions ------------------------------------ diff --git a/packages/interaction-controls/src/control.test.ts b/packages/interaction-controls/src/control.test.ts new file mode 100644 index 000000000..985f05bd5 --- /dev/null +++ b/packages/interaction-controls/src/control.test.ts @@ -0,0 +1,979 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "@fedify/fedify"; +import { + type DocumentLoader, + FetchError, + parseIri, + type PortableObjectVerifier, + preloadedContexts, + type RemoteDocument, + UrlError, + withGatewayHints, +} from "@fedify/vocab-runtime"; +import { + Delete, + InteractionPolicy, + InteractionRule, + Like, + LikeAuthorization, + LikeRequest, + Note, + PUBLIC_COLLECTION, +} from "@fedify/vocab"; +import { likeInteraction, quoteInteraction } from "./mod.ts"; + +const context = {} as Context; +const actor = new URL("https://example.com/users/alice"); +const author = new URL("https://example.net/users/bob"); +const targetId = new URL("https://example.net/notes/1"); +const likeId = new URL("https://example.com/likes/1"); +const requestId = new URL("https://example.com/requests/1"); +const authorizationId = new URL("https://example.net/authorizations/1"); +const followers = new URL("https://example.net/users/bob/followers"); +const following = new URL("https://example.net/users/bob/following"); + +function remoteDocument(url: URL | string, document: unknown): RemoteDocument { + return { contextUrl: null, document, documentUrl: url.toString() }; +} + +function httpError(url: string, status: number): FetchError { + return new FetchError( + url, + `HTTP ${status}`, + new Response(null, { status }), + ); +} + +/** + * Serves the given documents and preloaded JSON-LD contexts, and throws the + * given error for any other URL, without accessing the network. + */ +function documentLoader( + documents: Record, + error: (url: string) => unknown = (url) => httpError(url, 404), +): DocumentLoader { + return async (url: string) => { + await Promise.resolve(); + if (url in documents) return remoteDocument(url, documents[url]); + if (url in preloadedContexts) { + return remoteDocument(url, preloadedContexts[url]); + } + throw error(url); + }; +} + +function likeRequestWithInstrumentIri(): LikeRequest { + return new LikeRequest({ + id: requestId, + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: likeId, + }); +} + +test("verifyRequest() reports transient instrument fetch failures", async () => { + const error = httpError(likeId.href, 503); + + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({}, () => error), + }); + + assert.deepEqual(result.verified ? null : result.failure, { + category: "unverifiable", + type: "notDereferenceable", + url: likeId, + cause: error, + transient: true, + }); +}); + +test("verifyRequest() reports permanent instrument fetch failures", async () => { + for ( + const error of [ + httpError(likeId.href, 404), + new UrlError("Disallowed private URL"), + ] + ) { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({}, () => error), + }); + + if (result.verified || result.failure.category !== "unverifiable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.transient, false); + } +}); + +test("verifyRequest() reports DNS failures as transient", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader( + {}, + () => new UrlError("DNS lookup failed", { reason: "dns" }), + ), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.category !== "unverifiable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.transient, true); +}); + +test("verifyRequest() reports nullish loader rejections as transient", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: () => Promise.reject(null), + }); + + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, likeId.href); + assert.equal(result.failure.cause, null); + assert.equal(result.failure.transient, true); +}); + +test("verifyRequest() reports null loader results as not dereferenceable", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: async () => { + await Promise.resolve(); + return null as unknown as RemoteDocument; + }, + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, likeId.href); + assert.equal(result.failure.transient, false); +}); + +test("verifyRequest() reports cross-origin instruments as permanent failures", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({ + [likeId.href]: { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Like", + id: "https://evil.example/likes/1", + actor: actor.href, + object: targetId.href, + }, + }), + }); + + assert.deepEqual(result.verified ? null : result.failure, { + category: "unverifiable", + type: "notDereferenceable", + url: likeId, + transient: false, + }); +}); + +test("verifyRequest() reports failed instrument contexts with their URLs", async () => { + const contextUrl = "https://example.com/contexts/like"; + const error = httpError(contextUrl, 503); + + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({ + [likeId.href]: { + "@context": ["https://www.w3.org/ns/activitystreams", contextUrl], + type: "Like", + id: likeId.href, + actor: actor.href, + object: targetId.href, + }, + }, () => error), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, contextUrl); + assert.equal(result.failure.cause, error); + assert.equal(result.failure.transient, true); +}); + +test("verifyRequest() reports nullish context loader rejections as transient", async () => { + const contextUrl = "https://example.com/contexts/like"; + + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({ + [likeId.href]: { + "@context": ["https://www.w3.org/ns/activitystreams", contextUrl], + type: "Like", + id: likeId.href, + actor: actor.href, + object: targetId.href, + }, + }, () => null), + }); + + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, contextUrl); + assert.equal(result.failure.cause, null); + assert.equal(result.failure.transient, true); +}); + +test("verifyAuthorization() reports nullish context loader rejections as transient", async () => { + const contextUrl = "https://example.net/contexts/authorization"; + + const result = await likeInteraction.verifyAuthorization(context, { + authorization: authorizationId, + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + documentLoader: documentLoader({ + [authorizationId.href]: { + "@context": ["https://www.w3.org/ns/activitystreams", contextUrl], + type: "https://gotosocial.org/ns#LikeApproval", + id: authorizationId.href, + }, + }, () => undefined), + }); + + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, contextUrl); + assert.equal(result.failure.transient, true); +}); + +test("verifyRequest() reports failed request contexts with their URLs", async () => { + const contextUrl = "https://example.com/contexts/request"; + + const result = await likeInteraction.verifyRequest(context, { + request: requestId, + documentLoader: documentLoader({ + [requestId.href]: { + "@context": ["https://www.w3.org/ns/activitystreams", contextUrl], + type: "https://gotosocial.org/ns#LikeRequest", + id: requestId.href, + }, + }), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, contextUrl); + assert.equal(result.failure.transient, false); +}); + +test("verifyRequest() uses separate context loaders", async () => { + const contextUrl = "https://example.com/contexts/like"; + const loadedContexts: string[] = []; + + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: async (url: string) => { + await Promise.resolve(); + assert.equal(url, likeId.href); + return remoteDocument(url, { + "@context": ["https://www.w3.org/ns/activitystreams", contextUrl], + type: "Like", + id: likeId.href, + actor: actor.href, + object: targetId.href, + }); + }, + contextLoader: async (url: string) => { + loadedContexts.push(url); + if (url === contextUrl) return remoteDocument(url, { "@context": {} }); + return await documentLoader({})(url); + }, + }); + + assert.equal(result.verified, true); + assert.ok(loadedContexts.includes(contextUrl)); +}); + +test("verifyRequest() reports malformed instruments as invalid JSON-LD", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: documentLoader({ + [likeId.href]: { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Like", + id: likeId.href, + actor: actor.href, + object: targetId.href, + published: "not a date", + }, + }), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.category !== "unverifiable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.type, "invalidJsonLd"); + assert.equal(result.failure.transient, false); +}); + +test("verifyRequest() keeps loader errors of concurrent verifications apart", async () => { + const [transient, permanent] = await Promise.all([ + likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: async (url: string) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + throw httpError(url, 503); + }, + }), + likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: async (url: string) => { + await Promise.resolve(); + throw httpError(url, 404); + }, + }), + ]); + + assert.ok( + !transient.verified && transient.failure.category === "unverifiable" && + transient.failure.transient === true, + ); + assert.ok( + !permanent.verified && permanent.failure.category === "unverifiable" && + permanent.failure.transient === false, + ); +}); + +const portableLikeId = + "ap://did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK/likes/1"; + +function portableLikeRequest( + gateways: readonly string[], + verifyPortableObject: PortableObjectVerifier, +): LikeRequest { + return new LikeRequest({ + id: requestId, + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: withGatewayHints(portableLikeId, gateways), + }, { verifyPortableObject }); +} + +test("verifyRequest() reports any transient gateway failure as transient", async () => { + for ( + const gateways of [ + ["https://unavailable.example/", "https://missing.example/"], + ["https://missing.example/", "https://unavailable.example/"], + ] + ) { + const result = await likeInteraction.verifyRequest(context, { + request: portableLikeRequest( + gateways, + () => Promise.resolve({ verified: false }), + ), + documentLoader: documentLoader({}, (url) => + httpError( + url, + url.startsWith("https://unavailable.example/") ? 503 : 404, + )), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail("expected a notDereferenceable failure"); + } + assert.equal(result.failure.transient, true); + assert.ok(result.failure.cause instanceof AggregateError); + assert.equal(result.failure.url.origin, "https://unavailable.example"); + } +}); + +test("verifyRequest() reports failures of portable references without gateways", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: new LikeRequest({ + id: requestId, + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: parseIri(portableLikeId), + }, { verifyPortableObject: () => Promise.resolve({ verified: false }) }), + // FetchError cannot be constructed with a portable URL, so a network + // failure is used: + documentLoader: documentLoader({}, () => new TypeError("fetch failed")), + }); + + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.protocol, "ap+ef61:"); + assert.equal(result.failure.transient, true); +}); + +test("verification returns failures for unparseable context URLs", async () => { + const document = { + "@context": ["https://www.w3.org/ns/activitystreams", "::"], + type: "Like", + id: likeId.href, + actor: actor.href, + object: targetId.href, + }; + const loader = documentLoader( + { [likeId.href]: document }, + (url) => new TypeError(`Invalid URL: ${url}`), + ); + + const field = await likeInteraction.verifyRequest(context, { + request: likeRequestWithInstrumentIri(), + documentLoader: loader, + }); + assert.equal(field.verified, false); + + const request = await likeInteraction.verifyRequest(context, { + request: requestId, + documentLoader: documentLoader({ + [requestId.href]: { + ...document, + type: "https://gotosocial.org/ns#LikeRequest", + id: requestId.href, + }, + }, (url) => new TypeError(`Invalid URL: ${url}`)), + }); + assert.equal(request.verified, false); + + const authorization = await likeInteraction.verifyAuthorization(context, { + authorization: authorizationId, + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + documentLoader: documentLoader({ + [authorizationId.href]: { + "@context": ["https://www.w3.org/ns/activitystreams", "::"], + type: "https://gotosocial.org/ns#LikeApproval", + id: authorizationId.href, + }, + }, (url) => new TypeError(`Invalid URL: ${url}`)), + }); + assert.equal(authorization.verified, false); +}); + +test("verifyRequest() ignores gateway failures it recovered from", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: portableLikeRequest( + ["https://unavailable.example/", "https://available.example/"], + () => Promise.resolve({ verified: true }), + ), + documentLoader: async (url: string) => { + if (url.startsWith("https://unavailable.example/")) { + throw httpError(url, 503); + } else if (url.startsWith("https://available.example/")) { + return remoteDocument(url, { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Like", + id: portableLikeId, + actor: actor.href, + object: targetId.href, + }); + } + return await documentLoader({})(url); + }, + }); + + assert.equal(result.verified, true); +}); + +test("verifyRequest() does not retry rejected portable objects", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: portableLikeRequest( + ["https://unavailable.example/", "https://forged.example/"], + () => Promise.resolve({ verified: false }), + ), + documentLoader: async (url: string) => { + if (url.startsWith("https://unavailable.example/")) { + throw httpError(url, 503); + } else if (url.startsWith("https://forged.example/")) { + return remoteDocument(url, { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Like", + id: portableLikeId, + actor: actor.href, + object: targetId.href, + }); + } + return await documentLoader({})(url); + }, + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.category !== "unverifiable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.transient, false); +}); + +test("evaluatePolicy() uses fallback rules", async () => { + const fallbackRule = new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + }); + + for ( + const subject of [ + new Note({ id: targetId, attribution: author }), + new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ automaticApproval: author }), + }), + }), + ] + ) { + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject, + requester: actor, + fallbackRule, + }), + { result: "automatic", reason: { type: "public" } }, + ); + } + + const quotable = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canQuote: new InteractionRule({ manualApproval: PUBLIC_COLLECTION }), + }), + }); + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject: quotable, + requester: actor, + fallbackRule, + }), + { result: "manual", reason: { type: "public" } }, + ); + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject: new Note({ id: targetId, attribution: author }), + requester: actor, + fallbackRule: new InteractionRule({}), + }), + { result: "denied", reason: { type: "missingPolicy" } }, + ); + assert.deepEqual( + await quoteInteraction.evaluatePolicy(context, { + subject: new Note({ id: targetId, attribution: author }), + requester: actor, + fallbackRule: new InteractionRule({ automaticApproval: author }), + }), + { result: "denied", reason: { type: "noMatch" } }, + ); +}); + +test("evaluatePolicy() can give automatic approval precedence", async () => { + const subject = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + manualApproval: actor, + }), + }), + }); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + }), + { result: "manual", reason: { type: "actor", actor } }, + ); + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + precedence: "automatic", + }), + { result: "automatic", reason: { type: "public" } }, + ); +}); + +test("evaluatePolicy() combines precedence with collection error handling", async () => { + const subject = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: followers, + manualApproval: PUBLIC_COLLECTION, + }), + }), + }); + const error = new Error("database unavailable"); + const matchesApprovalCollection = () => { + throw error; + }; + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + matchesApprovalCollection, + }), + { result: "manual", reason: { type: "public" } }, + ); + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + matchesApprovalCollection, + precedence: "automatic", + }), + { + result: "denied", + reason: { + type: "unverifiableCollection", + collection: followers, + cause: error, + }, + }, + ); + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject: new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ manualApproval: followers }), + }), + }), + requester: actor, + matchesApprovalCollection, + precedence: "automatic", + }), + { + result: "denied", + reason: { + type: "unverifiableCollection", + collection: followers, + cause: error, + }, + }, + ); + for (const precedence of ["actor", "automatic"] as const) { + await assert.rejects( + likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + matchesApprovalCollection, + precedence, + collectionErrors: "throw", + }), + error, + ); + } +}); + +test("evaluatePolicy() stops at the first collection error when throwing", async () => { + const subject = new Note({ + id: targetId, + attribution: author, + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApprovals: [followers, following], + }), + }), + }); + const error = new Error("database unavailable"); + const checked: string[] = []; + const matchesApprovalCollection = (collection: URL) => { + checked.push(collection.href); + if (collection.href === followers.href) throw error; + return true; + }; + + await assert.rejects( + likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + matchesApprovalCollection, + collectionErrors: "throw", + }), + error, + ); + assert.deepEqual(checked, [followers.href]); + + assert.deepEqual( + await likeInteraction.evaluatePolicy(context, { + subject, + requester: actor, + matchesApprovalCollection, + }), + { + result: "automatic", + reason: { type: "collection", collection: following }, + }, + ); +}); + +function likeAuthorization(id: URL = authorizationId): LikeAuthorization { + return new LikeAuthorization({ + id, + attribution: author, + interactingObject: likeId, + interactionTarget: targetId, + }); +} + +test("verifyAuthorization() checks expected IDs of authorization objects", async () => { + const like = new Like({ id: likeId, actor, object: targetId }); + const target = new Note({ id: targetId, attribution: author }); + const otherId = new URL("https://example.net/authorizations/2"); + + const mismatched = await likeInteraction.verifyAuthorization(context, { + authorization: likeAuthorization(), + authorizationId: otherId, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity: () => true, + }); + assert.deepEqual(mismatched.verified ? null : mismatched.failure, { + category: "unauthorized", + type: "idMismatch", + expected: otherId, + actual: authorizationId, + }); + + const matched = await likeInteraction.verifyAuthorization(context, { + authorization: likeAuthorization(), + authorizationId, + interactingObject: like, + interactionTarget: target, + verifyAuthenticity: () => true, + }); + assert.equal(matched.verified, true); + + const unauthenticated = await likeInteraction.verifyAuthorization(context, { + authorization: likeAuthorization(), + authorizationId, + interactingObject: like, + interactionTarget: target, + }); + assert.equal( + unauthenticated.verified ? null : unauthenticated.failure.type, + "notAuthentic", + ); +}); + +test("verifyAuthorization() checks expected IDs of authorization URLs before fetching", async () => { + const otherId = new URL("https://example.net/authorizations/2"); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization: otherId, + authorizationId, + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + documentLoader: () => assert.fail("must not fetch"), + }); + + assert.deepEqual(result.verified ? null : result.failure, { + category: "unauthorized", + type: "idMismatch", + expected: authorizationId, + actual: otherId, + }); +}); + +test("verifyAuthorization() accepts off-origin authorizations only when allowed and authentic", async () => { + const aliasId = new URL("https://alias.example/authorizations/1"); + const options = { + authorization: likeAuthorization(aliasId), + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + }; + + const disallowed = await likeInteraction.verifyAuthorization(context, { + ...options, + verifyAuthenticity: () => true, + }); + assert.equal( + disallowed.verified ? null : disallowed.failure.type, + "originMismatch", + ); + + const withoutVerifier = await likeInteraction.verifyAuthorization(context, { + ...options, + allowOffOrigin: true, + }); + assert.equal( + withoutVerifier.verified ? null : withoutVerifier.failure.type, + "originMismatch", + ); + + const notAuthentic = await likeInteraction.verifyAuthorization(context, { + ...options, + allowOffOrigin: true, + verifyAuthenticity: () => false, + }); + assert.equal( + notAuthentic.verified ? null : notAuthentic.failure.type, + "notAuthentic", + ); + + const allowed = await likeInteraction.verifyAuthorization(context, { + ...options, + allowOffOrigin: true, + verifyAuthenticity: () => true, + }); + assert.equal(allowed.verified, true); + + const wrongTarget = await likeInteraction.verifyAuthorization(context, { + ...options, + interactionTarget: new URL("https://example.net/notes/2"), + allowOffOrigin: true, + verifyAuthenticity: () => true, + }); + assert.equal( + wrongTarget.verified ? null : wrongTarget.failure.type, + "targetMismatch", + ); +}); + +test("verifyAuthorization() fetches allowed off-origin authorization URLs", async () => { + const aliasId = new URL("https://alias.example/authorizations/1"); + + const result = await likeInteraction.verifyAuthorization(context, { + authorization: aliasId, + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + allowOffOrigin: true, + verifyAuthenticity: (authorization) => + authorization.id?.href === aliasId.href, + documentLoader: documentLoader({ + [aliasId.href]: await likeAuthorization(aliasId).toJsonLd(), + }), + }); + + assert.equal(result.verified, true); +}); + +test("verifyAuthorization() reports transient authorization fetch failures", async () => { + const result = await likeInteraction.verifyAuthorization(context, { + authorization: authorizationId, + interactingObject: likeId, + interactionTarget: targetId, + attributedTo: author, + documentLoader: documentLoader({}, (url) => httpError(url, 502)), + }); + + assert.equal(result.verified, false); + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.href, authorizationId.href); + assert.equal(result.failure.transient, true); +}); + +test("response constructors do not require IDs or recipients", async () => { + const request = likeRequestWithInstrumentIri(); + + const accept = likeInteraction.createAccept({ + mode: "polite", + actor: author, + request, + authorization: authorizationId, + }); + const reject = likeInteraction.createReject({ + mode: "polite", + actor: author, + request, + }); + const revocation = likeInteraction.createRevocation({ + actor: author, + authorization: authorizationId, + }); + + for (const activity of [accept, reject, revocation]) { + assert.equal(activity.id, null); + assert.deepEqual(activity.toIds, []); + assert.deepEqual(activity.ccIds, []); + } + assert.equal( + (await revocation.toJsonLd() as Record).object, + authorizationId.href, + ); +}); + +test("createRevocation() can embed authorizations without leaking objects", async () => { + const authorization = new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: new Like({ id: likeId, actor, object: targetId }), + interactionTarget: new Note({ + id: targetId, + attribution: author, + content: "secret", + }), + }); + + const embedded = likeInteraction.createRevocation({ + id: new URL("https://example.net/deletes/1"), + actor: author, + authorization, + to: actor, + embedAuthorization: true, + }); + + assert.ok(embedded instanceof Delete); + const json = await embedded.toJsonLd() as Record; + assert.deepEqual(json.object, { + type: "LikeAuthorization", + id: authorizationId.href, + attributedTo: author.href, + interactingObject: likeId.href, + interactionTarget: targetId.href, + }); + assert.ok(authorization.interactionTargetId != null); + assert.ok( + !(await authorization.getInteractionTarget() instanceof URL), + "the given authorization must be left untouched", + ); + + const referenced = likeInteraction.createRevocation({ + actor: author, + authorization, + }); + assert.equal( + (await referenced.toJsonLd() as Record).object, + authorizationId.href, + ); + + assert.throws( + () => + likeInteraction.createRevocation({ + actor: author, + authorization: new LikeAuthorization({ + id: authorizationId, + attribution: author, + interactingObject: new Like({ actor, object: targetId }), + interactionTarget: targetId, + }), + embedAuthorization: true, + }), + TypeError, + ); +}); diff --git a/packages/interaction-controls/src/control.ts b/packages/interaction-controls/src/control.ts index fd7f432c3..4ff5595ae 100644 --- a/packages/interaction-controls/src/control.ts +++ b/packages/interaction-controls/src/control.ts @@ -1,5 +1,11 @@ import type { Context } from "@fedify/fedify"; -import { type DocumentLoader, getFe34Origin } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + FetchError, + getFe34Origin, + isTransientFetchError, + parseIri, +} from "@fedify/vocab-runtime"; import { Accept, type Activity, @@ -22,6 +28,7 @@ import type { InteractionPolicyProperty, InteractionRejectOptions, InteractionRequestVerification, + InteractionRequestVerificationFailure, InteractionRequestVerificationOptions, MatchesApprovalCollection, RecognizedImpoliteInteraction, @@ -49,6 +56,7 @@ interface ControlConfig< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object = Record, > { readonly name: InteractionName; readonly policyProperty: InteractionPolicyProperty; @@ -76,6 +84,7 @@ interface ControlConfig< interactingObject: TInteracting, interactionTarget: TTarget, requester: URL, + options: TRequestValidationOptions, ) => RequestValidationFailure | null; readonly authorizationAttribution?: "required" | "optional"; readonly getSelfActor: (subject: TTarget) => URL | null; @@ -121,6 +130,7 @@ type RuleMatchResult = | { readonly result: "unverifiableCollection"; readonly collection: URL; + readonly cause: unknown; } | null; @@ -130,20 +140,23 @@ export function createInteractionControl< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object = Record, >( config: ControlConfig< TRequest, TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): InteractionControl< TRequest, TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions > { return { name: config.name, @@ -183,7 +196,10 @@ export function createInteractionControl< new Delete({ id: options.id, actor: options.actor, - object: getRequiredId(options.authorization, "authorization"), + object: options.embedAuthorization && + !(options.authorization instanceof URL) + ? createEmbeddedAuthorization(options.authorization, config) + : getRequiredId(options.authorization, "authorization"), ...audience(options), }), recognizeImpolite: config.recognizeImpolite, @@ -241,6 +257,50 @@ export function getRequiredId(value: ASObject | URL, name: string): URL { return value.id; } +function createEmbeddedAuthorization< + TRequest extends Activity, + TAuthorization extends ASObject, + TInteracting extends ASObject, + TTarget extends ASObject, + TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, +>( + authorization: TAuthorization, + config: ControlConfig< + TRequest, + TAuthorization, + TInteracting, + TTarget, + TImpoliteSource, + TRequestValidationOptions + >, +): TAuthorization { + const { interactingObjectId, interactionTargetId } = authorization as + & ASObject + & { + readonly interactingObjectId?: URL | null; + readonly interactionTargetId?: URL | null; + }; + if (interactingObjectId == null) { + throw new TypeError( + "The authorization's interactingObject must have an id.", + ); + } + if (interactionTargetId == null) { + throw new TypeError( + "The authorization's interactionTarget must have an id.", + ); + } + // Only IDs are copied, so that the revocation does not leak the + // interacting object or the interaction target (FEP-044f): + return new config.authorizationClass({ + id: getRequiredId(authorization, "authorization"), + attributions: authorization.attributionIds, + interactingObject: interactingObjectId, + interactionTarget: interactionTargetId, + }); +} + function getId(value: ASObject | URL): URL | null { return value instanceof URL ? value : value.id; } @@ -262,19 +322,24 @@ async function verifyRequest< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, TContextData, >( context: Context, - options: InteractionRequestVerificationOptions, + options: + & InteractionRequestVerificationOptions + & TRequestValidationOptions, config: ControlConfig< TRequest, TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise> { const documentLoader = options.documentLoader ?? context.documentLoader; + const contextLoader = options.contextLoader ?? documentLoader; const expectedRequestId = options.request instanceof URL ? options.request : null; @@ -282,6 +347,7 @@ async function verifyRequest< options.request, config.requestClass, documentLoader, + contextLoader, ); if (!requestResult.ok) { return { @@ -323,15 +389,26 @@ async function verifyRequest< }, }; } - const dereferenceOptions = { - documentLoader, - contextLoader: documentLoader, - suppressError: true, - }; - const interactionTarget = await config.getInteractionTarget( - request, - dereferenceOptions, - ); + const targetResult = options.resolvedInteractionTarget != null && + request.objectId != null + // The type is checked by isInteractionTarget() below: + ? { ok: true as const, value: options.resolvedInteractionTarget as TTarget } + : await dereferenceField( + request, + request.objectId, + config.getInteractionTarget, + documentLoader, + contextLoader, + ); + if (!targetResult.ok) { + return { + verified: false, + request, + requestId: request.id, + failure: targetResult.failure, + }; + } + const interactionTarget = targetResult.value; if (interactionTarget == null) { return { verified: false, @@ -356,10 +433,29 @@ async function verifyRequest< }, }; } - const interactingObject = await config.getInteractingObject( - request, - dereferenceOptions, - ); + const interactingResult = options.resolvedInteractingObject != null && + request.instrumentId != null + // The type is checked by isInteractingObject() below: + ? { + ok: true as const, + value: options.resolvedInteractingObject as TInteracting, + } + : await dereferenceField( + request, + request.instrumentId, + config.getInteractingObject, + documentLoader, + contextLoader, + ); + if (!interactingResult.ok) { + return { + verified: false, + request, + requestId: request.id, + failure: interactingResult.failure, + }; + } + const interactingObject = interactingResult.value; if (interactingObject == null) { return { verified: false, @@ -417,6 +513,7 @@ async function verifyRequest< interactingObject, interactionTarget, requester, + options, ); if (validation != null) { return { @@ -456,6 +553,7 @@ async function verifyAuthorization< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, TContextData, >( context: Context, @@ -470,27 +568,48 @@ async function verifyAuthorization< TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise> { - const embeddedAuthorization = !(options.authorization instanceof URL); - const expectedAuthorizationId = options.authorization instanceof URL + const authorizationUrl = options.authorization instanceof URL ? options.authorization : null; + const embeddedAuthorization = authorizationUrl == null; + const expectedAuthorizationId = options.authorizationId ?? authorizationUrl; + // An off-origin authorization is acceptable only if the caller vouches for + // its authenticity: + const checkOrigin = !options.allowOffOrigin || + options.verifyAuthenticity == null; const expectedAttribution = options.attributedTo ?? (!(options.interactionTarget instanceof URL) ? config.getSelfActor(options.interactionTarget) : null); - if (expectedAuthorizationId != null && expectedAttribution == null) { + if ( + authorizationUrl != null && options.authorizationId != null && + !idsEqual(authorizationUrl, options.authorizationId) + ) { return { verified: false, - authorizationId: expectedAuthorizationId, + authorizationId: authorizationUrl, + failure: { + category: "unauthorized", + type: "idMismatch", + expected: options.authorizationId, + actual: authorizationUrl, + }, + }; + } + if (authorizationUrl != null && expectedAttribution == null) { + return { + verified: false, + authorizationId: authorizationUrl, failure: { category: "unauthorized", type: "missingAttribution" }, }; } - if (expectedAuthorizationId != null && expectedAttribution != null) { + if (checkOrigin && authorizationUrl != null && expectedAttribution != null) { const expectedOrigin = getAuthorizationOrigin(expectedAttribution); - const actualOrigin = getAuthorizationOrigin(expectedAuthorizationId); + const actualOrigin = getAuthorizationOrigin(authorizationUrl); if ( expectedOrigin === "null" || actualOrigin === "null" || @@ -498,7 +617,7 @@ async function verifyAuthorization< ) { return { verified: false, - authorizationId: expectedAuthorizationId, + authorizationId: authorizationUrl, failure: { category: "unauthorized", type: "originMismatch", @@ -508,10 +627,12 @@ async function verifyAuthorization< }; } } + const documentLoader = options.documentLoader ?? context.documentLoader; const authorizationResult = await materialize( options.authorization, config.authorizationClass, - options.documentLoader ?? context.documentLoader, + documentLoader, + options.contextLoader ?? documentLoader, ); if (!authorizationResult.ok) { return { verified: false, failure: authorizationResult.failure }; @@ -656,9 +777,11 @@ async function verifyAuthorization< const expectedOrigin = getAuthorizationOrigin(expectedAttribution); const actualOrigin = getAuthorizationOrigin(authorization.id); if ( - expectedOrigin === "null" || - actualOrigin === "null" || - expectedOrigin !== actualOrigin + checkOrigin && ( + expectedOrigin === "null" || + actualOrigin === "null" || + expectedOrigin !== actualOrigin + ) ) { return { verified: false, @@ -708,78 +831,217 @@ async function verifyAuthorization< return { verified: true, authorization, authorizationId: authorization.id }; } -async function materialize( - value: T | URL, - constructor: VocabConstructor, +type UnverifiableFailure = Extract< + InteractionRequestVerificationFailure, + { + readonly category: "unverifiable"; + readonly type: "notDereferenceable" | "invalidJsonLd"; + } +>; + +interface LoaderTracker { + readonly documentLoader: DocumentLoader | undefined; + readonly contextLoader: DocumentLoader | undefined; + readonly records: ReadonlyMap; + readonly release: () => void; +} + +/** + * Wraps the loaders for a single dereferencing operation so that errors they + * throw can be told apart from other errors afterwards. Parsed objects keep + * the loaders they were given, so the wrappers stop recording and pass calls + * through once the operation is released. + */ +function trackLoaders( documentLoader: DocumentLoader | undefined, -): Promise< - | { readonly ok: true; readonly object: T } - | { - readonly ok: false; - readonly failure: - | { - readonly category: "unverifiable"; - readonly type: "notDereferenceable"; - readonly url: URL; - readonly cause?: unknown; + contextLoader: DocumentLoader | undefined, +): LoaderTracker { + const records = new Map(); + let released = false; + const wrap = ( + loader: DocumentLoader | undefined, + ): DocumentLoader | undefined => { + if (loader == null) return undefined; + return async (url, options) => { + if (released) return await loader(url, options); + let remoteDocument; + try { + remoteDocument = await loader(url, options); + } catch (error) { + if (!released) records.set(error, url); + throw error; } - | { - readonly category: "unverifiable"; - readonly type: "invalidJsonLd"; - readonly cause?: unknown; - }; + if (remoteDocument == null) { + const error = new FetchError( + url, + "The document loader returned no document.", + ); + if (!released) records.set(error, url); + throw error; + } + return remoteDocument; + }; + }; + const wrappedDocumentLoader = wrap(documentLoader); + return { + documentLoader: wrappedDocumentLoader, + contextLoader: contextLoader === documentLoader + ? wrappedDocumentLoader + : wrap(contextLoader), + records, + release: () => { + released = true; + records.clear(); + }, + }; +} + +const MAX_CAUSE_DEPTH = 8; + +function collectCauses( + error: unknown, + causes: unknown[] = [], + depth = 0, +): unknown[] { + if (depth > MAX_CAUSE_DEPTH || causes.includes(error)) return causes; + causes.push(error); + if (typeof error !== "object" || error == null) return causes; + if (error instanceof AggregateError) { + for (const e of error.errors) collectCauses(e, causes, depth + 1); } -> { - if (!(value instanceof URL)) return { ok: true, object: value }; - const loader = documentLoader; - if (loader == null) { + // Loaders may throw anything, even null, so check for the properties + // rather than their values: + if ("cause" in error) collectCauses(error.cause, causes, depth + 1); + // jsonld.js reports a failed remote context load as `details.cause`: + const details = (error as { readonly details?: unknown }).details; + if (typeof details === "object" && details != null && "cause" in details) { + collectCauses(details.cause, causes, depth + 1); + } + return causes; +} + +/** + * Classifies the error thrown by a dereferencing operation. It is a fetch + * failure only if a loader error is among its causes; errors that loaders + * threw during attempts the operation recovered from are ignored. + */ +function classifyFailure( + error: unknown, + tracker: LoaderTracker, +): UnverifiableFailure { + const loaderErrors = collectCauses(error).filter((e) => + tracker.records.has(e) + ); + // Loaders may throw anything, even null, so look up indices, not values: + const transientIndex = loaderErrors.findIndex(isTransientFetchError); + const selected = loaderErrors[transientIndex < 0 ? 0 : transientIndex]; + const url = loaderErrors.length < 1 + ? null + : getFailedUrl(selected, tracker.records.get(selected)!); + // A document referring to a URL that cannot even be parsed is malformed: + if (url == null) { return { - ok: false, - failure: { - category: "unverifiable", - type: "notDereferenceable", - url: value, - }, + category: "unverifiable", + type: "invalidJsonLd", + cause: error, + transient: false, }; } - let remoteDocument; + return { + category: "unverifiable", + type: "notDereferenceable", + url, + cause: loaderErrors.length === 1 ? selected : error, + transient: transientIndex >= 0, + }; +} + +function getFailedUrl(error: unknown, requestedUrl: string): URL | null { + const url = (error as { readonly url?: unknown } | null)?.url; + if (url instanceof URL) return url; + for (const candidate of [url, requestedUrl]) { + if (typeof candidate !== "string") continue; + try { + return parseIri(candidate); + } catch { + continue; + } + } + return null; +} + +function notDereferenceable(url: URL): UnverifiableFailure { + return { + category: "unverifiable", + type: "notDereferenceable", + url, + transient: false, + }; +} + +/** + * Dereferences a field of the request, such as its `object` or `instrument`. + * Returns `null` if the request has no such field at all. + */ +async function dereferenceField( + request: TRequest, + reference: URL | null, + dereference: ( + request: TRequest, + options: DereferenceOptions, + ) => Promise, + documentLoader: DocumentLoader | undefined, + contextLoader: DocumentLoader | undefined, +): Promise< + | { readonly ok: true; readonly value: T | null } + | { readonly ok: false; readonly failure: UnverifiableFailure } +> { + const tracker = trackLoaders(documentLoader, contextLoader); try { - remoteDocument = await loader(value.href); - } catch (cause) { - return { - ok: false, - failure: { - category: "unverifiable", - type: "notDereferenceable", - url: value, - cause, - }, - }; + const value = await dereference(request, { + documentLoader: tracker.documentLoader, + contextLoader: tracker.contextLoader, + suppressError: false, + }); + if (value != null || reference == null) return { ok: true, value }; + // Fetch failures throw, so a null result for an existing reference means + // the dereferenced document was rejected, e.g., for its origin: + return { ok: false, failure: notDereferenceable(reference) }; + } catch (error) { + return { ok: false, failure: classifyFailure(error, tracker) }; + } finally { + tracker.release(); } - if (remoteDocument == null) { - return { - ok: false, - failure: { - category: "unverifiable", - type: "notDereferenceable", - url: value, - }, - }; +} + +async function materialize( + value: T | URL, + constructor: VocabConstructor, + documentLoader: DocumentLoader | undefined, + contextLoader: DocumentLoader | undefined, +): Promise< + | { readonly ok: true; readonly object: T } + | { readonly ok: false; readonly failure: UnverifiableFailure } +> { + if (!(value instanceof URL)) return { ok: true, object: value }; + if (documentLoader == null) { + return { ok: false, failure: notDereferenceable(value) }; } + const tracker = trackLoaders(documentLoader, contextLoader); try { + const remoteDocument = await tracker.documentLoader!(value.href); return { ok: true, object: await constructor.fromJsonLd(remoteDocument.document, { - documentLoader: loader, - contextLoader: loader, + documentLoader: tracker.documentLoader, + contextLoader: tracker.contextLoader, baseUrl: value, }), }; - } catch (cause) { - return { - ok: false, - failure: { category: "unverifiable", type: "invalidJsonLd", cause }, - }; + } catch (error) { + return { ok: false, failure: classifyFailure(error, tracker) }; + } finally { + tracker.release(); } } @@ -789,6 +1051,7 @@ async function evaluatePolicy< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, TContextData, >( context: Context, @@ -798,7 +1061,8 @@ async function evaluatePolicy< TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise { const selfActor = config.getSelfActor(options.subject); @@ -818,55 +1082,64 @@ async function evaluatePolicy< return { result: "automatic", reason: implicitAutomatic }; } const policy = options.subject.interactionPolicy; - if (policy == null) return missingPolicyDecision(config); - const rule = policy[config.policyProperty] as InteractionRule | null; - if (rule == null) return missingRuleDecision(config); - const automaticApprovals = rule.automaticApprovals ?? []; - const manualApprovals = rule.manualApprovals ?? []; - if (automaticApprovals.length < 1 && manualApprovals.length < 1) { - return missingRuleDecision(config); + let rule = getApprovalRule( + policy?.[config.policyProperty] as InteractionRule | null | undefined, + ); + if (rule == null) { + rule = getApprovalRule(options.fallbackRule); + if (rule == null) { + return policy == null + ? missingPolicyDecision(config) + : missingRuleDecision(config); + } } - const automatic = await matchRule( - automaticApprovals, - options.requester, + const { automaticApprovals, manualApprovals } = rule; + const matchOptions = { + requester: options.requester, context, - options.matchesApprovalCollection, - { actorOnly: true }, - ); + matchesApprovalCollection: options.matchesApprovalCollection, + throwCollectionErrors: options.collectionErrors === "throw", + }; + if (options.precedence === "automatic") { + const automatic = await matchRule(automaticApprovals, matchOptions); + if (automatic?.result === "matched") { + return { result: "automatic", reason: automatic.reason }; + } else if (automatic?.result === "unverifiableCollection") { + return deniedUnverifiableCollection(automatic); + } + const manual = await matchRule(manualApprovals, matchOptions); + if (manual?.result === "matched") { + return { result: "manual", reason: manual.reason }; + } else if (manual?.result === "unverifiableCollection") { + return deniedUnverifiableCollection(manual); + } + return { result: "denied", reason: { type: "noMatch" } }; + } + const automatic = await matchRule(automaticApprovals, { + ...matchOptions, + actorOnly: true, + }); if (automatic?.result === "matched") { return { result: "automatic", reason: automatic.reason }; } - const manual = await matchRule( - manualApprovals, - options.requester, - context, - options.matchesApprovalCollection, - { actorOnly: true }, - ); + const manual = await matchRule(manualApprovals, { + ...matchOptions, + actorOnly: true, + }); if (manual?.result === "matched") { return { result: "manual", reason: manual.reason }; } - const broadAutomatic = await matchRule( - automaticApprovals, - options.requester, - context, - options.matchesApprovalCollection, - ); + const broadAutomatic = await matchRule(automaticApprovals, matchOptions); const unverifiableAutomatic = broadAutomatic?.result === "unverifiableCollection" - ? broadAutomatic.collection + ? broadAutomatic : null; if (broadAutomatic?.result === "matched") { return { result: "automatic", reason: broadAutomatic.reason }; } - const broadManual = await matchRule( - manualApprovals, - options.requester, - context, - options.matchesApprovalCollection, - ); + const broadManual = await matchRule(manualApprovals, matchOptions); if (broadManual?.result === "unverifiableCollection") { - return deniedUnverifiableCollection(broadManual.collection); + return deniedUnverifiableCollection(broadManual); } else if (broadManual?.result === "matched") { return { result: "manual", reason: broadManual.reason }; } @@ -876,12 +1149,28 @@ async function evaluatePolicy< return { result: "denied", reason: { type: "noMatch" } }; } +function getApprovalRule( + rule: InteractionRule | null | undefined, +): { + readonly automaticApprovals: readonly URL[]; + readonly manualApprovals: readonly URL[]; +} | null { + if (rule == null) return null; + const automaticApprovals = rule.automaticApprovals ?? []; + const manualApprovals = rule.manualApprovals ?? []; + if (automaticApprovals.length < 1 && manualApprovals.length < 1) { + return null; + } + return { automaticApprovals, manualApprovals }; +} + async function matchImplicitAutomaticActors< TRequest extends Activity, TAuthorization extends ASObject, TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, >( subject: TTarget, requester: URL, @@ -891,7 +1180,8 @@ async function matchImplicitAutomaticActors< TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise { if (config.getImplicitAutomaticActors == null) return null; @@ -911,13 +1201,15 @@ function missingPolicyDecision< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, >( config: ControlConfig< TRequest, TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): InteractionPolicyDecision { if (config.defaultMissingPolicy === "automatic") { @@ -935,13 +1227,15 @@ function missingRuleDecision< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, >( config: ControlConfig< TRequest, TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): InteractionPolicyDecision { if (config.defaultMissingPolicy === "automatic") { @@ -955,13 +1249,17 @@ function missingRuleDecision< async function matchRule( entries: readonly URL[], - requester: URL, - context: Context, - matchesApprovalCollection: - | MatchesApprovalCollection - | undefined, - options: { readonly actorOnly?: boolean } = {}, + options: { + readonly requester: URL; + readonly context: Context; + readonly matchesApprovalCollection?: MatchesApprovalCollection< + TContextData + >; + readonly throwCollectionErrors: boolean; + readonly actorOnly?: boolean; + }, ): Promise { + const { requester, matchesApprovalCollection } = options; for (const entry of entries) { if (entry.href === requester.href) { return { result: "matched", reason: { type: "actor", actor: entry } }; @@ -974,7 +1272,9 @@ async function matchRule( } } if (matchesApprovalCollection != null) { - let unverifiableCollection: URL | null = null; + let unverifiable: + | { readonly collection: URL; readonly cause: unknown } + | null = null; for (const entry of entries) { if ( entry.href === PUBLIC_COLLECTION.href || entry.href === requester.href @@ -983,9 +1283,14 @@ async function matchRule( } let matched; try { - matched = await matchesApprovalCollection(entry, requester, context); - } catch { - unverifiableCollection ??= entry; + matched = await matchesApprovalCollection( + entry, + requester, + options.context, + ); + } catch (cause) { + if (options.throwCollectionErrors) throw cause; + unverifiable ??= { collection: entry, cause }; continue; } if (matched) { @@ -995,22 +1300,23 @@ async function matchRule( }; } } - if (unverifiableCollection != null) { - return { - result: "unverifiableCollection", - collection: unverifiableCollection, - }; + if (unverifiable != null) { + return { result: "unverifiableCollection", ...unverifiable }; } } return null; } function deniedUnverifiableCollection( - collection: URL, + failure: { readonly collection: URL; readonly cause: unknown }, ): InteractionPolicyDecision { return { result: "denied", - reason: { type: "unverifiableCollection", collection }, + reason: { + type: "unverifiableCollection", + collection: failure.collection, + cause: failure.cause, + }, }; } @@ -1082,6 +1388,7 @@ export function recognized< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, >( values: { readonly requester: URL | null; diff --git a/packages/interaction-controls/src/like.test.ts b/packages/interaction-controls/src/like.test.ts index 8e619c391..aed76d8b2 100644 --- a/packages/interaction-controls/src/like.test.ts +++ b/packages/interaction-controls/src/like.test.ts @@ -143,13 +143,14 @@ test("likeInteraction denies failed collection policy checks", async () => { canLike: new InteractionRule({ automaticApproval: followers }), }), }); + const error = new Error("collection unavailable"); assert.deepEqual( await likeInteraction.evaluatePolicy(context, { subject: target, requester: actor, matchesApprovalCollection: () => { - throw new Error("collection unavailable"); + throw error; }, }), { @@ -157,6 +158,7 @@ test("likeInteraction denies failed collection policy checks", async () => { reason: { type: "unverifiableCollection", collection: followers, + cause: error, }, }, ); @@ -659,7 +661,7 @@ test("likeInteraction rejects wrong request instrument types", async () => { assert.equal(result.failure.type, "wrongInstrumentType"); }); -test("likeInteraction returns failures for dereferenceable object errors", async () => { +test("likeInteraction reports object fetch failures as not dereferenceable", async () => { const like = new Like({ id: likeId, actor, object: targetId }); const request = new LikeRequest({ id: new URL("https://example.com/requests/1"), @@ -674,10 +676,13 @@ test("likeInteraction returns failures for dereferenceable object errors", async }); assert.equal(result.verified, false); - assert.equal(result.failure.type, "missingObject"); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.url.href, targetId.href); + assert.equal(result.failure.transient, true); + assert.equal((result.failure.cause as Error).message, "not dereferenceable"); }); -test("likeInteraction returns failures for dereferenceable instrument errors", async () => { +test("likeInteraction reports instrument fetch failures as not dereferenceable", async () => { const target = new Note({ id: targetId, attribution: author }); const request = new LikeRequest({ id: new URL("https://example.com/requests/1"), @@ -692,7 +697,9 @@ test("likeInteraction returns failures for dereferenceable instrument errors", a }); assert.equal(result.verified, false); - assert.equal(result.failure.type, "missingInstrument"); + assert.equal(result.failure.type, "notDereferenceable"); + assert.equal(result.failure.url.href, likeId.href); + assert.equal(result.failure.transient, true); }); test("likeInteraction rejects id-less request objects", async () => { diff --git a/packages/interaction-controls/src/quote.test.ts b/packages/interaction-controls/src/quote.test.ts index 42bc2a9dc..cd1136469 100644 --- a/packages/interaction-controls/src/quote.test.ts +++ b/packages/interaction-controls/src/quote.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import type { Context } from "@fedify/fedify"; import { + Announce, InteractionPolicy, InteractionRule, Note, @@ -9,7 +10,11 @@ import { QuoteAuthorization, QuoteRequest, } from "@fedify/vocab"; -import { quoteInteraction } from "./mod.ts"; +import { type DocumentLoader, preloadedContexts } from "@fedify/vocab-runtime"; +import { + type InteractionRequestVerificationOptions, + quoteInteraction, +} from "./mod.ts"; const context = {} as Context; const actor = new URL("https://example.com/users/alice"); @@ -202,3 +207,341 @@ test("quoteInteraction recognizes bare quote objects", () => { assert.equal(recognized.interactionTargetId.href, targetId.href); assert.equal(recognized.evidence.type, "property"); }); + +const elsewhereId = new URL("https://example.net/notes/elsewhere"); +const failingDocumentLoader: DocumentLoader = (url) => + assert.fail(`must not fetch ${url}`); + +function quoteRequest(quote: Note): QuoteRequest { + return new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: quote, + }); +} + +test("quoteInteraction can prefer quote over a conflicting quoteUrl", async () => { + const request = quoteRequest( + new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + quoteUrl: elsewhereId, + }), + ); + + const strict = await quoteInteraction.verifyRequest(context, { request }); + assert.deepEqual(strict.verified ? null : strict.failure, { + category: "invalid", + type: "objectMismatch", + expected: targetId, + actual: elsewhereId, + }); + + const lenient = await quoteInteraction.verifyRequest(context, { + request, + quoteReference: "preferQuote", + }); + assert.equal(lenient.verified, true); +}); + +test("quoteInteraction can accept either quote reference", async () => { + const request = quoteRequest( + new Note({ + id: quoteId, + attribution: actor, + quote: elsewhereId, + quoteUrl: targetId, + }), + ); + + const preferQuote = await quoteInteraction.verifyRequest(context, { + request, + quoteReference: "preferQuote", + }); + assert.deepEqual(preferQuote.verified ? null : preferQuote.failure, { + category: "invalid", + type: "objectMismatch", + expected: targetId, + actual: elsewhereId, + }); + + const any = await quoteInteraction.verifyRequest(context, { + request, + quoteReference: "any", + }); + assert.equal(any.verified, true); + + const neither = await quoteInteraction.verifyRequest(context, { + request: quoteRequest( + new Note({ + id: quoteId, + attribution: actor, + quote: elsewhereId, + quoteUrl: new URL("https://example.net/notes/other"), + }), + ), + quoteReference: "any", + }); + assert.equal( + neither.verified ? null : neither.failure.type, + "objectMismatch", + ); +}); + +test("quoteInteraction can match the requester against any attribution", async () => { + const request = quoteRequest( + new Note({ + id: quoteId, + attributions: [author, actor], + quote: targetId, + }), + ); + + const first = await quoteInteraction.verifyRequest(context, { request }); + assert.deepEqual(first.verified ? null : first.failure, { + category: "unauthorized", + type: "requesterMismatch", + expected: actor, + actual: author, + }); + + const any = await quoteInteraction.verifyRequest(context, { + request, + attribution: "any", + }); + assert.equal(any.verified, true); +}); + +test("quoteInteraction can treat missing attribution as the requester", async () => { + const request = quoteRequest(new Note({ id: quoteId, quote: targetId })); + + const strict = await quoteInteraction.verifyRequest(context, { request }); + assert.equal( + strict.verified ? null : strict.failure.type, + "requesterMismatch", + ); + + const lenient = await quoteInteraction.verifyRequest(context, { + request, + missingAttribution: "requester", + }); + assert.equal(lenient.verified, true); + assert.equal(lenient.verified && lenient.requester.href, actor.href); + + const mismatched = await quoteInteraction.verifyRequest(context, { + request: quoteRequest( + new Note({ id: quoteId, attribution: author, quote: targetId }), + ), + missingAttribution: "requester", + attribution: "any", + }); + assert.equal( + mismatched.verified ? null : mismatched.failure.type, + "requesterMismatch", + ); +}); + +test("quoteInteraction treats ID-less attributions as missing without fetching", async () => { + const quote = await Note.fromJsonLd({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://example.com/contexts/unavailable", + ], + type: "Note", + id: quoteId.href, + attributedTo: { + "@context": "https://example.com/contexts/unavailable", + type: "Person", + name: "Anonymous", + }, + quote: targetId.href, + }, { + documentLoader: failingDocumentLoader, + contextLoader: async (url) => { + await Promise.resolve(); + if (url === "https://example.com/contexts/unavailable") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": { + quote: { + "@id": "https://w3id.org/fep/044f#quote", + "@type": "@id", + }, + }, + }, + }; + } else if (url in preloadedContexts) { + return { + contextUrl: null, + documentUrl: url, + document: preloadedContexts[url], + }; + } + return assert.fail(`must not fetch ${url}`); + }, + }); + const request = quoteRequest(quote); + + const result = await quoteInteraction.verifyRequest(context, { + request, + missingAttribution: "requester", + documentLoader: failingDocumentLoader, + contextLoader: failingDocumentLoader, + }); + + assert.equal(result.verified, true); +}); + +test("quoteInteraction verifies requests with resolved objects", async () => { + const shareId = new URL("https://example.net/shares/1"); + const target = new Note({ id: targetId, attribution: author }); + const quote = new Note({ + id: new URL("https://example.com/notes/redirected"), + attribution: actor, + quote: targetId, + }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: shareId, + instrument: quoteId, + }); + const before = await request.toJsonLd(); + + const result = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: target, + resolvedInteractingObject: quote, + documentLoader: failingDocumentLoader, + }); + + assert.equal(result.verified, true); + assert.equal(result.verified && result.request, request); + assert.equal( + result.verified && result.interactionTargetId.href, + targetId.href, + ); + assert.equal( + result.verified && result.interactingObjectId.href, + "https://example.com/notes/redirected", + ); + assert.deepEqual(await request.toJsonLd(), before); + assert.equal(request.objectId?.href, shareId.href); + assert.equal(request.instrumentId?.href, quoteId.href); +}); + +test("quoteInteraction still validates resolved objects", async () => { + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: targetId, + instrument: quoteId, + }); + const target = new Note({ id: targetId, attribution: author }); + const before = await request.toJsonLd(); + + const wrongType = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: target, + resolvedInteractingObject: new Announce({ id: quoteId, actor }), + documentLoader: failingDocumentLoader, + }); + assert.equal( + wrongType.verified ? null : wrongType.failure.type, + "wrongInstrumentType", + ); + + const wrongAuthor = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: target, + resolvedInteractingObject: new Note({ + id: quoteId, + attribution: author, + quote: targetId, + }), + documentLoader: failingDocumentLoader, + }); + assert.equal( + wrongAuthor.verified ? null : wrongAuthor.failure.type, + "requesterMismatch", + ); + + const wrongTarget = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: target, + resolvedInteractingObject: new Note({ + id: quoteId, + attribution: actor, + quote: elsewhereId, + }), + documentLoader: failingDocumentLoader, + }); + assert.equal( + wrongTarget.verified ? null : wrongTarget.failure.type, + "objectMismatch", + ); + assert.deepEqual(await request.toJsonLd(), before); +}); + +test("quoteInteraction does not fill missing request fields with resolved objects", async () => { + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + instrument: quoteId, + }); + + const result = await quoteInteraction.verifyRequest(context, { + request, + resolvedInteractionTarget: new Note({ id: targetId, attribution: author }), + resolvedInteractingObject: new Note({ + id: quoteId, + attribution: actor, + quote: targetId, + }), + documentLoader: failingDocumentLoader, + }); + + assert.equal(result.verified ? null : result.failure.type, "missingObject"); +}); + +test("quoteInteraction leaves clones taken before verification untouched", async () => { + const quote = new Note({ id: quoteId, attribution: actor, quote: targetId }); + const request = new QuoteRequest({ + id: new URL("https://example.com/requests/4"), + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: quoteId, + }); + const original = request.clone(); + + const result = await quoteInteraction.verifyRequest(context, { + request, + documentLoader: async (url) => ({ + contextUrl: null, + documentUrl: url, + document: url in preloadedContexts + ? preloadedContexts[url] + : (assert.equal(url, quoteId.href), await quote.toJsonLd()), + }), + }); + + assert.equal(result.verified, true); + const json = await original.toJsonLd() as Record; + assert.equal(json.instrument, quoteId.href); +}); + +test("quoteInteraction accepts options typed for older versions", async () => { + const options: InteractionRequestVerificationOptions = { + request: quoteRequest( + new Note({ id: quoteId, attribution: actor, quote: targetId }), + ), + }; + + const result = await quoteInteraction.verifyRequest(context, options); + + assert.equal(result.verified, true); +}); diff --git a/packages/interaction-controls/src/quote.ts b/packages/interaction-controls/src/quote.ts index 501b49502..d3a439783 100644 --- a/packages/interaction-controls/src/quote.ts +++ b/packages/interaction-controls/src/quote.ts @@ -14,7 +14,10 @@ import { idsEqual, recognized, } from "./control.ts"; -import type { InteractionControl } from "./types.ts"; +import type { + InteractionControl, + QuoteRequestValidationOptions, +} from "./types.ts"; export type QuotePost = Note | Article | Question | ChatMessage; @@ -31,7 +34,8 @@ export const quoteInteraction: InteractionControl< QuoteAuthorization, QuotePost, ASObject, - QuoteImpoliteSource + QuoteImpoliteSource, + QuoteRequestValidationOptions > = createInteractionControl({ name: "quote", policyProperty: "canQuote", @@ -52,9 +56,17 @@ export const quoteInteraction: InteractionControl< ], getInteractionTarget: (request, options) => request.getObject(options) as Promise, - validateRequest: (_request, quote, target, requester) => { + validateRequest: ( + _request, + quote, + target, + requester, + options: QuoteRequestValidationOptions, + ) => { const targetId = getRequiredId(target, "interactionTarget"); + const quoteReference = options.quoteReference ?? "strict"; if ( + quoteReference === "strict" && quote.quoteId != null && quote.quoteUrl != null && !idsEqual(quote.quoteUrl, quote.quoteId) ) { @@ -65,14 +77,26 @@ export const quoteInteraction: InteractionControl< }; } const quoteTargetId = getQuoteTargetId(quote); - if (!idsEqual(quoteTargetId, targetId)) { + const targetMatched = quoteReference === "any" + ? idsEqual(quote.quoteId, targetId) || idsEqual(quote.quoteUrl, targetId) + : idsEqual(quoteTargetId, targetId); + if (!targetMatched) { return { type: "objectMismatch", expected: targetId, actual: quoteTargetId ?? undefined, }; } - if (!idsEqual(quote.attributionId, requester)) { + const attributionIds = quote.attributionIds; + if ( + attributionIds.length < 1 && options.missingAttribution === "requester" + ) { + return null; + } + const attributionMatched = options.attribution === "any" + ? attributionIds.some((id) => idsEqual(id, requester)) + : idsEqual(quote.attributionId, requester); + if (!attributionMatched) { return { type: "requesterMismatch", expected: requester, diff --git a/packages/interaction-controls/src/types.ts b/packages/interaction-controls/src/types.ts index c4bba3f5b..d8f3312e4 100644 --- a/packages/interaction-controls/src/types.ts +++ b/packages/interaction-controls/src/types.ts @@ -5,6 +5,7 @@ import type { Accept, Activity, Delete, + InteractionRule, Object as ASObject, Reject, } from "@fedify/vocab"; @@ -29,6 +30,7 @@ export interface InteractionControl< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object = Record, > { readonly name: InteractionName; readonly policyProperty: InteractionPolicyProperty; @@ -36,7 +38,9 @@ export interface InteractionControl< readonly authorizationTypeId: URL; readonly verifyRequest: ( context: Context, - options: InteractionRequestVerificationOptions, + options: + & InteractionRequestVerificationOptions + & TRequestValidationOptions, ) => Promise< InteractionRequestVerification< TRequest, @@ -99,6 +103,85 @@ export interface InteractionRequestVerificationOptions< > { readonly request: TRequest | URL; readonly documentLoader?: DocumentLoader; + + /** + * The document loader for remote JSON-LD contexts. Defaults to the + * document loader used for objects. + * @since 2.5.0 + */ + readonly contextLoader?: DocumentLoader; + + /** + * The already resolved interaction target, used instead of dereferencing + * the request's `object`. It is used only when the request references its + * `object` by ID; otherwise the request fails as it would without this + * option. When it is used, the request itself is left untouched. + * + * The value is trusted as the resolution of the request's `object`: its ID + * does not have to match the reference (e.g., a share wrapper resolved to + * the shared post), and no origin or provenance checks are applied to it. + * The caller is responsible for those checks. The target type and target + * binding checks still apply. + * @since 2.5.0 + */ + readonly resolvedInteractionTarget?: ASObject; + + /** + * The already resolved interacting object, used instead of dereferencing + * the request's `instrument`. It is used only when the request references + * its `instrument` by ID; otherwise the request fails as it would without + * this option. When it is used, the request itself is left untouched. + * + * The value is trusted as the resolution of the request's `instrument`: its + * ID does not have to match the reference (e.g., after a redirect), and no + * origin or provenance checks are applied to it. The caller is responsible + * for those checks, such as checking that the instrument comes from the + * requester's origin; a matching attribution alone does not authenticate + * it. The instrument type, requester, and target binding checks still + * apply. + * @since 2.5.0 + */ + readonly resolvedInteractingObject?: ASObject; +} + +/** + * Options for relaxing how {@link quoteInteraction} validates a quote request. + * The defaults apply the strictest checks. + * @since 2.5.0 + */ +export interface QuoteRequestValidationOptions { + /** + * How the quote post's FEP-044f `quote` and compatible `quoteUrl` + * references are checked against the requested target: + * + * - `"strict"` (default): if both are present they must agree, and the + * reference must equal the target. + * - `"preferQuote"`: `quote` must equal the target if present, otherwise + * `quoteUrl` must; a conflicting `quoteUrl` is ignored. + * - `"any"`: either `quote` or `quoteUrl` equal to the target suffices, + * even when the two disagree. + */ + readonly quoteReference?: "strict" | "preferQuote" | "any"; + + /** + * Which `attributedTo` entries of the quote post may match the requester: + * + * - `"first"` (default): the first attribution must be the requester. + * - `"any"`: any attribution may be the requester. + */ + readonly attribution?: "first" | "any"; + + /** + * What to do when the quote post has no attribution IRI, i.e., it has no + * `attributedTo` at all or only attributions embedded without an `id`: + * + * - `"reject"` (default): fail with `requesterMismatch`. + * - `"requester"`: treat the quote post as attributed to the requester. + * + * A present attribution IRI that does not match the requester always + * fails. + */ + readonly missingAttribution?: "reject" | "requester"; } export type InteractionRequestVerification< @@ -129,16 +212,33 @@ export type InteractionRequestVerificationFailure = readonly type: "notDereferenceable"; readonly url: URL; readonly cause?: unknown; + /** + * Whether the failure is likely transient, so that verifying again later + * could succeed. Always set by the built-in helpers. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "unverifiable"; readonly type: "unauthorizedFetchRequired"; readonly url: URL; + /** + * Whether the failure is likely transient. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "unverifiable"; readonly type: "invalidJsonLd"; readonly cause?: unknown; + /** + * Whether the failure is likely transient. Always `false` when set by + * the built-in helpers. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "invalid"; @@ -189,6 +289,45 @@ export interface InteractionPolicyEvaluationOptions< readonly requester: URL; readonly documentLoader?: DocumentLoader; readonly matchesApprovalCollection?: MatchesApprovalCollection; + + /** + * The rule to evaluate when the subject has no interaction policy, no rule + * for this interaction, or a rule without any approval entries. Without + * it, such subjects get the interaction's default decision. + * @since 2.5.0 + */ + readonly fallbackRule?: InteractionRule; + + /** + * The order in which approval entries are matched: + * + * - `"actor"` (default): actors listed explicitly in either + * `automaticApproval` or `manualApproval` first, then the public + * collection and other collections, automatic before manual. An actor + * listed in `manualApproval` thus gets a manual decision even when + * `automaticApproval` contains the public collection. + * - `"automatic"`: every `automaticApproval` entry first, then every + * `manualApproval` entry. If an `automaticApproval` collection cannot + * be checked and no other automatic entry matches, the decision is + * `denied` with an `unverifiableCollection` reason rather than a manual + * decision, since the collection could have granted automatic + * approval. + * @since 2.5.0 + */ + readonly precedence?: "actor" | "automatic"; + + /** + * What to do when {@link matchesApprovalCollection} throws: + * + * - `"deny"` (default): skip the collection; if nothing else decides, + * the decision is `denied` with an `unverifiableCollection` reason + * carrying the error as its `cause`. + * - `"throw"`: rethrow the first error immediately, without calling the + * callback for later collections, so that the caller can retry later + * instead of rejecting the interaction. + * @since 2.5.0 + */ + readonly collectionErrors?: "deny" | "throw"; } export type MatchesApprovalCollection = ( @@ -222,7 +361,15 @@ export type InteractionPolicyDenialReason = | { readonly type: "missingPolicy" } | { readonly type: "missingRule" } | { readonly type: "noMatch" } - | { readonly type: "unverifiableCollection"; readonly collection: URL }; + | { + readonly type: "unverifiableCollection"; + readonly collection: URL; + /** + * The error thrown while checking the collection. + * @since 2.5.0 + */ + readonly cause?: unknown; + }; export interface InteractionRequestCreationOptions< TInteracting extends ASObject, @@ -257,6 +404,37 @@ export interface InteractionAuthorizationVerificationOptions< readonly interactionTarget: TTarget | URL; readonly attributedTo?: URL; readonly documentLoader?: DocumentLoader; + + /** + * The document loader for remote JSON-LD contexts. Defaults to the + * document loader used for objects. + * @since 2.5.0 + */ + readonly contextLoader?: DocumentLoader; + + /** + * The expected ID of the authorization. When the authorization is given + * as an object, its ID must equal this, or verification fails with + * `idMismatch`. When it is given as a URL, the URL must equal this. + * + * This only checks the identity of the authorization; it does not + * establish its authenticity. An authorization given as an object still + * needs {@link verifyAuthenticity}. + * @since 2.5.0 + */ + readonly authorizationId?: URL; + + /** + * Whether to accept an authorization whose ID is on a different origin + * than its attributed actor, if {@link verifyAuthenticity} approves it. + * This is for authorizations whose authenticity is established by other + * means, such as a signed `Accept` from the attributed actor or a locally + * stored grant. Without {@link verifyAuthenticity}, such authorizations + * still fail with `originMismatch`. All other checks still apply. + * Defaults to `false`. + * @since 2.5.0 + */ + readonly allowOffOrigin?: boolean; readonly getRevocation?: GetInteractionAuthorizationRevocation; readonly verifyAuthenticity?: ( authorization: TAuthorization, @@ -285,16 +463,33 @@ export type InteractionAuthorizationVerificationFailure = readonly type: "notDereferenceable"; readonly url: URL; readonly cause?: unknown; + /** + * Whether the failure is likely transient, so that verifying again later + * could succeed. Always set by the built-in helpers. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "unverifiable"; readonly type: "unauthorizedFetchRequired"; readonly url: URL; + /** + * Whether the failure is likely transient. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "unverifiable"; readonly type: "invalidJsonLd"; readonly cause?: unknown; + /** + * Whether the failure is likely transient. Always `false` when set by + * the built-in helpers. + * @since 2.5.0 + */ + readonly transient?: boolean; } | { readonly category: "unauthorized"; @@ -364,21 +559,21 @@ export type InteractionAcceptOptions< > = | { readonly mode: "polite"; - readonly id: URL; + readonly id?: URL; readonly actor: URL; readonly request: TRequest | URL; readonly authorization: TAuthorization | URL; - readonly to: URL | readonly URL[]; + readonly to?: URL | readonly URL[]; readonly cc?: URL | readonly URL[]; } | { readonly mode: "impolite"; - readonly id: URL; + readonly id?: URL; readonly actor: URL; readonly interactingObject: TInteracting | URL; readonly interactionTarget: TTarget | URL; readonly authorization: TAuthorization | URL; - readonly to: URL | readonly URL[]; + readonly to?: URL | readonly URL[]; readonly cc?: URL | readonly URL[]; }; @@ -389,30 +584,40 @@ export type InteractionRejectOptions< > = | { readonly mode: "polite"; - readonly id: URL; + readonly id?: URL; readonly actor: URL; readonly request: TRequest | URL; - readonly to: URL | readonly URL[]; + readonly to?: URL | readonly URL[]; readonly cc?: URL | readonly URL[]; } | { readonly mode: "impolite"; - readonly id: URL; + readonly id?: URL; readonly actor: URL; readonly interactingObject: TInteracting | URL; readonly interactionTarget: TTarget | URL; - readonly to: URL | readonly URL[]; + readonly to?: URL | readonly URL[]; readonly cc?: URL | readonly URL[]; }; export interface InteractionRevocationCreationOptions< TAuthorization extends ASObject, > { - readonly id: URL; + readonly id?: URL; readonly actor: URL; readonly authorization: TAuthorization | URL; - readonly to: URL | readonly URL[]; + readonly to?: URL | readonly URL[]; readonly cc?: URL | readonly URL[]; + + /** + * Whether to embed the authorization in the `Delete` activity instead of + * referring to it by its ID. Applies only when the authorization is given + * as an object. The embedded copy contains only the authorization's ID, + * attribution, and the IDs of its interacting object and interaction + * target, so that the revocation does not leak them. Defaults to `false`. + * @since 2.5.0 + */ + readonly embedAuthorization?: boolean; } export interface RecognizedImpoliteInteraction< diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index 3fcf46b07..946f28b83 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -70,6 +70,7 @@ export { FetchError, getUserAgent, type GetUserAgentOptions, + isTransientFetchError, logRequest, } from "./request.ts"; export { diff --git a/packages/vocab-runtime/src/request.test.ts b/packages/vocab-runtime/src/request.test.ts index 253657345..42297f643 100644 --- a/packages/vocab-runtime/src/request.test.ts +++ b/packages/vocab-runtime/src/request.test.ts @@ -2,7 +2,13 @@ import { deepStrictEqual } from "node:assert"; import process from "node:process"; import { test } from "node:test"; import metadata from "../deno.json" with { type: "json" }; -import { createActivityPubRequest, getUserAgent } from "./request.ts"; +import { + createActivityPubRequest, + FetchError, + getUserAgent, + isTransientFetchError, +} from "./request.ts"; +import { UrlError } from "./url.ts"; test("getUserAgent()", () => { if ("Deno" in globalThis) { @@ -100,3 +106,139 @@ test("createActivityPubRequest() asks for the ActivityStreams profile", () => { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', ); }); + +test("isTransientFetchError() classifies document loader errors", () => { + const url = "https://example.com/object"; + const response = (status: number) => new Response(null, { status }); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "HTTP 503", response(503))), + true, + ); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "HTTP 408", response(408))), + true, + ); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "HTTP 429", response(429))), + true, + ); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "HTTP 404", response(404))), + false, + ); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "HTTP 410", response(410))), + false, + ); + deepStrictEqual( + isTransientFetchError(new FetchError(url, "Redirect loop detected")), + false, + ); + const timeout = new FetchError(url, "Timed out after 1000 ms"); + timeout.cause = new DOMException("Timed out", "TimeoutError"); + deepStrictEqual(isTransientFetchError(timeout), true); + const tooLarge = new FetchError(url, "Body exceeds the limit"); + tooLarge.name = "BodyTooLargeError"; + deepStrictEqual(isTransientFetchError(tooLarge), false); + deepStrictEqual( + isTransientFetchError(new UrlError("DNS lookup failed", { reason: "dns" })), + true, + ); + deepStrictEqual( + isTransientFetchError(new UrlError("Disallowed private URL")), + false, + ); + deepStrictEqual( + isTransientFetchError( + new TypeError("fetch failed", { cause: new Error("ECONNRESET") }), + ), + true, + ); + let invalidUrl: unknown; + try { + new URL("::"); + } catch (error) { + invalidUrl = error; + } + deepStrictEqual(isTransientFetchError(invalidUrl), false); + deepStrictEqual( + isTransientFetchError( + new TypeError("Failed to parse URL from ::", { + cause: Object.assign(new TypeError("Invalid URL"), { + code: "ERR_INVALID_URL", + }), + }), + ), + false, + ); + deepStrictEqual(isTransientFetchError(new SyntaxError("Bad JSON")), false); + deepStrictEqual( + isTransientFetchError(new DOMException("Aborted", "AbortError")), + true, + ); + deepStrictEqual(isTransientFetchError(new Error("unknown")), true); +}); + +test("isTransientFetchError() recognizes errors from other package copies", () => { + const dns = Object.assign(new Error("DNS lookup failed"), { + name: "UrlError", + reason: "dns", + }); + deepStrictEqual(isTransientFetchError(dns), true); + const notFound = Object.assign(new Error("HTTP 404"), { + name: "FetchError", + url: new URL("https://example.com/"), + response: new Response(null, { status: 404 }), + }); + deepStrictEqual(isTransientFetchError(notFound), false); +}); + +test("isTransientFetchError() classifies aggregate errors by their members", () => { + const notFound = (url: string) => + new FetchError(url, "HTTP 404", new Response(null, { status: 404 })); + const unavailable = (url: string) => + new FetchError(url, "HTTP 503", new Response(null, { status: 503 })); + deepStrictEqual( + isTransientFetchError( + new AggregateError([ + notFound("https://a.example/"), + notFound("https://b.example/"), + ]), + ), + false, + ); + deepStrictEqual( + isTransientFetchError( + new AggregateError([ + notFound("https://a.example/"), + unavailable("https://b.example/"), + ]), + ), + true, + ); + deepStrictEqual(isTransientFetchError(new AggregateError([])), true); + const gone = new FetchError( + "https://a.example/", + "HTTP 410", + new Response(null, { status: 410 }), + ); + deepStrictEqual( + isTransientFetchError(new AggregateError([gone, gone])), + false, + ); + deepStrictEqual( + isTransientFetchError( + new AggregateError([ + new FetchError("https://a.example/", "a", undefined), + new FetchError("https://b.example/", "b", undefined), + ].map((e) => Object.assign(e, { cause: gone }))), + ), + false, + ); +}); + +test("isTransientFetchError() tolerates cyclic causes", () => { + const error = new FetchError("https://example.com/", "cyclic"); + error.cause = error; + deepStrictEqual(isTransientFetchError(error), true); +}); diff --git a/packages/vocab-runtime/src/request.ts b/packages/vocab-runtime/src/request.ts index b0ac72952..5d88dace8 100644 --- a/packages/vocab-runtime/src/request.ts +++ b/packages/vocab-runtime/src/request.ts @@ -1,6 +1,7 @@ import type { Logger } from "@logtape/logtape"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; +import { UrlError } from "./url.ts"; /** * Error thrown when fetching a JSON-LD document failed. @@ -31,6 +32,118 @@ export class FetchError extends Error { } } +const MAX_CAUSE_DEPTH = 8; + +/** + * Guesses whether an error thrown by a document loader is transient, + * i.e., whether fetching the same document again later could succeed. + * + * This is a heuristic classification, not a guarantee. Callers that retry + * on transient errors should still bound the number of retries. The rules + * are: + * + * - A {@link UrlError} is transient only if its + * {@link UrlError.reason | reason} is `"dns"`; URLs disallowed by SSRF + * protection are permanent. + * - A {@link FetchError} with a {@link FetchError.response | response} is + * transient if the status is 5xx, 408 (Request Timeout), or 429 (Too Many + * Requests); other statuses such as 404 are permanent. + * - A {@link FetchError} without a response is classified by its `cause`, + * which is how timeouts are reported. Without a cause, it is permanent + * (e.g., redirect loops, too many redirections, or bodies that exceed the + * size limit). + * - A `TimeoutError` or `AbortError` is transient. + * - A `TypeError` from parsing an invalid URL is permanent; any other + * `TypeError` is transient, since `fetch()` reports network failures + * that way. + * - A `SyntaxError` (a malformed JSON body) is permanent. + * - An `AggregateError`, e.g., from trying several gateways, is transient + * if any of its errors is. + * - Any other error is treated as transient. + * + * @param error The error thrown by a document loader. + * @returns `true` if the error is likely transient, `false` if retrying is + * unlikely to help. + * @since 2.5.0 + */ +export function isTransientFetchError(error: unknown): boolean { + return classifyFetchError(error, new Set(), 0); +} + +function classifyFetchError( + error: unknown, + path: Set, + depth: number, +): boolean { + // Only an error on the current path is a cycle; the same error can appear + // in several branches, e.g., twice in an AggregateError: + if (depth > MAX_CAUSE_DEPTH || path.has(error)) return true; + path.add(error); + try { + return classifyErrorShape(error, path, depth); + } finally { + path.delete(error); + } +} + +function classifyErrorShape( + error: unknown, + path: Set, + depth: number, +): boolean { + if (!(error instanceof Error) && !isNamedError(error)) return true; + const name = (error as { readonly name: string }).name; + if (error instanceof UrlError || name === "UrlError") { + return (error as { readonly reason?: unknown }).reason === "dns"; + } + if (name === "BodyTooLargeError") return false; + if (error instanceof FetchError || name === "FetchError") { + const response = (error as { readonly response?: unknown }).response; + if (isResponseLike(response)) { + const status = response.status; + return status >= 500 || status === 408 || status === 429; + } + const cause = (error as { readonly cause?: unknown }).cause; + if (cause == null) return false; + return classifyFetchError(cause, path, depth + 1); + } + if (error instanceof AggregateError || name === "AggregateError") { + const errors = (error as { readonly errors?: unknown }).errors; + if (!Array.isArray(errors) || errors.length < 1) return true; + return errors.some((e) => classifyFetchError(e, path, depth + 1)); + } + if (name === "TimeoutError" || name === "AbortError") return true; + if (error instanceof TypeError || name === "TypeError") { + // Node.js's fetch() wraps the URL parsing error as its cause: + const cause = (error as { readonly cause?: unknown }).cause; + return !isInvalidUrlError(error) && !isInvalidUrlError(cause); + } + if (error instanceof SyntaxError || name === "SyntaxError") return false; + return true; +} + +function isNamedError(error: unknown): boolean { + return typeof error === "object" && error != null && + typeof (error as { readonly name?: unknown }).name === "string"; +} + +function isResponseLike( + value: unknown, +): value is { readonly status: number } { + return typeof value === "object" && value != null && + typeof (value as { readonly status?: unknown }).status === "number"; +} + +function isInvalidUrlError(error: unknown): boolean { + if (typeof error !== "object" || error == null) return false; + const { code, message } = error as { + readonly code?: unknown; + readonly message?: unknown; + }; + return code === "ERR_INVALID_URL" || + typeof message === "string" && message.startsWith("Invalid URL"); +} + /** * The `Accept` header value for fetching ActivityPub objects. ActivityPub * and FEP-ef61 gateways require the ActivityStreams profile on the JSON-LD From 14af6d7ed925cd3e433afa474cd9168872df5005 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 00:01:02 +0900 Subject: [PATCH 2/2] Record null loader results for portable URLs When a document loader returned no document, the loader wrapper built a FetchError from the requested URL. FetchError's constructor parses the URL with new URL(), which throws for portable ap+ef61 URLs, so the error was never recorded and the failure was reported as invalidJsonLd instead of notDereferenceable. The wrapper now parses the URL with parseIri() and falls back to a plain Error, so the failure is always recorded. https://github.com/fedify-dev/fedify/pull/1245#discussion_r4185405811 Changelog: none Assisted-by: Claude Code:claude-opus-5-5 Claude-Session: https://claude.ai/code/session_01MTz8EqRkcCsxbf9B7Wf95U --- .../interaction-controls/src/control.test.ts | 21 +++++++++++++++++++ packages/interaction-controls/src/control.ts | 12 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/interaction-controls/src/control.test.ts b/packages/interaction-controls/src/control.test.ts index 985f05bd5..e48615342 100644 --- a/packages/interaction-controls/src/control.test.ts +++ b/packages/interaction-controls/src/control.test.ts @@ -418,6 +418,27 @@ test("verifyRequest() reports failures of portable references without gateways", assert.equal(result.failure.transient, true); }); +test("verifyRequest() reports null loader results for portable references", async () => { + const result = await likeInteraction.verifyRequest(context, { + request: new LikeRequest({ + id: requestId, + actor, + object: new Note({ id: targetId, attribution: author }), + instrument: parseIri(portableLikeId), + }, { verifyPortableObject: () => Promise.resolve({ verified: false }) }), + documentLoader: async (url: string) => { + if (url in preloadedContexts) return await documentLoader({})(url); + return null as unknown as RemoteDocument; + }, + }); + + if (result.verified || result.failure.type !== "notDereferenceable") { + assert.fail(`unexpected result: ${String(result.verified)}`); + } + assert.equal(result.failure.url.protocol, "ap+ef61:"); + assert.equal(result.failure.transient, false); +}); + test("verification returns failures for unparseable context URLs", async () => { const document = { "@context": ["https://www.w3.org/ns/activitystreams", "::"], diff --git a/packages/interaction-controls/src/control.ts b/packages/interaction-controls/src/control.ts index 4ff5595ae..b643da623 100644 --- a/packages/interaction-controls/src/control.ts +++ b/packages/interaction-controls/src/control.ts @@ -872,10 +872,14 @@ function trackLoaders( throw error; } if (remoteDocument == null) { - const error = new FetchError( - url, - "The document loader returned no document.", - ); + const message = "The document loader returned no document."; + let error: Error; + try { + // FetchError's constructor cannot parse portable URLs by itself: + error = new FetchError(parseIri(url), message); + } catch { + error = new Error(`${url}: ${message}`); + } if (!released) records.set(error, url); throw error; }