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..e48615342 --- /dev/null +++ b/packages/interaction-controls/src/control.test.ts @@ -0,0 +1,1000 @@ +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("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", "::"], + 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..b643da623 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: authorizationUrl, + failure: { + category: "unauthorized", + type: "idMismatch", + expected: options.authorizationId, + actual: authorizationUrl, + }, + }; + } + if (authorizationUrl != null && expectedAttribution == null) { return { verified: false, - authorizationId: expectedAuthorizationId, + 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,221 @@ 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 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; + } + 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 +1055,7 @@ async function evaluatePolicy< TInteracting extends ASObject, TTarget extends ASObject, TImpoliteSource extends ASObject, + TRequestValidationOptions extends object, TContextData, >( context: Context, @@ -798,7 +1065,8 @@ async function evaluatePolicy< TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise { const selfActor = config.getSelfActor(options.subject); @@ -818,55 +1086,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 +1153,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 +1184,8 @@ async function matchImplicitAutomaticActors< TAuthorization, TInteracting, TTarget, - TImpoliteSource + TImpoliteSource, + TRequestValidationOptions >, ): Promise { if (config.getImplicitAutomaticActors == null) return null; @@ -911,13 +1205,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 +1231,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 +1253,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 +1276,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 +1287,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 +1304,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 +1392,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