diff --git a/.agents/skills/add-vocab/SKILL.md b/.agents/skills/add-vocab/SKILL.md index 664001e2f..94d0b286e 100644 --- a/.agents/skills/add-vocab/SKILL.md +++ b/.agents/skills/add-vocab/SKILL.md @@ -204,7 +204,8 @@ Generates `get()` async iterable and optionally a singular accessor. Required: `pluralName`, `singularName`, `uri`, `description`, `range` Optional: `singularAccessor` (default `false`), `compactName`, `subpropertyOf`, -`container` (`"graph"` or `"list"`), `embedContext`, `untyped` +`container` (`"graph"` or `"list"`), `embedContext`, `untyped`, +`redundantProperties`, `redundantPropertiesWrite` ### Functional property (exactly one value) @@ -223,13 +224,21 @@ Generates a single `get()` / `` accessor. Required: `singularName`, `functional: true`, `uri`, `description`, `range` Optional: `compactName`, `subpropertyOf`, `redundantProperties`, `untyped`, -`embedContext` +`embedContext`, `redundantPropertiesWrite` -### Redundant properties (functional only) +### Redundant properties When a property has equivalent URIs from multiple vocabularies, use -`redundantProperties` to write all aliases on serialization and try them in -order on deserialization: +`redundantProperties` on functional or non-functional properties. Reading +selects the first non-empty raw value set, starting with the canonical property +and then aliases in declaration order, before decoding values and containers. +It never merges aliases or retries invalid values. Writing defaults to all +aliases; set `redundantPropertiesWrite: canonical` to write only the canonical +property. An alias without `compactName` uses the JSON-LD processor, as do +writable list and graph aliases. IRI-valued compact terms need +`"@type": "@id"` in the context. + +For example: ~~~~ yaml - singularName: quoteUrl @@ -304,6 +313,7 @@ Range type reference | --------------------- | ------------------------------------------------------ | ----------------------------------- | | `fedify:langTag` | `Intl.Locale` | BCP 47 language tag as plain string | | `fedify:url` | `URL` | URL stored as `@value` (not `@id`) | +| `fedify:absoluteIri` | `URL` | Absolute IRI stored as `@id`; also reads literals, never resolves relative values | | `fedify:publicKey` | `CryptoKey` | PEM SPKI-encoded public key | | `fedify:multibaseKey` | `CryptoKey` | Multibase-encoded key (Ed25519) | | `fedify:proofPurpose` | `"assertionMethod" \| "authentication" \| ...` | Proof purpose string | diff --git a/.agents/skills/create-integration-package/SKILL.md b/.agents/skills/create-integration-package/SKILL.md index b62e7df76..563945fc4 100644 --- a/.agents/skills/create-integration-package/SKILL.md +++ b/.agents/skills/create-integration-package/SKILL.md @@ -180,7 +180,8 @@ environments. ### Other updates Refer to the “Adding a new package” section in *CONTRIBUTING.md* and -perform the required updates. Record the package addition in *CHANGES.md*. +perform the required updates. Record the package addition as a changelog +fragment under *changes.d/*; never edit *CHANGES.md* by hand. ### Tests @@ -243,7 +244,7 @@ At a minimum, test the following scenarios (see 8. Write tests if possible 9. Perform remaining updates per the “Adding a new package” section in *CONTRIBUTING.md* -10. Record changes in *CHANGES.md* +10. Record changes as a changelog fragment under *changes.d/* Lint, format, and final checks diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 337f857ac..c62167986 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -11,6 +11,15 @@ permissions: contents: read jobs: + test-sveltekit-compat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/setup-mise + - run: mise run --skip-deps test:sveltekit-compat + test-astro-compat: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index e9d69ff2e..6e6a9f149 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -99,6 +99,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: .test-report.xml report_type: test_results + continue-on-error: true - run: deno coverage --lcov .cov > .cov.lcov continue-on-error: true - uses: codecov/codecov-action@v6 diff --git a/.hongdown.toml b/.hongdown.toml index cdb0f7e0d..c5e861ada 100644 --- a/.hongdown.toml +++ b/.hongdown.toml @@ -45,6 +45,7 @@ proper_nouns = [ "Activity Streams", "Activity Vocabulary", "ActivityKit", + "ActivityPub Fuzzer", "ActivityPub", "ActivityStreams", "AdonisJS", @@ -67,6 +68,7 @@ proper_nouns = [ "Fastify", "Fedify", "Elysia", + "Economiverse", "Encyclia", "ESLint", "Express", @@ -78,6 +80,8 @@ proper_nouns = [ "Hono", "HTTP Message Signatures", "HTTP Signatures", + "Indiekit", + "IndieWeb", "JSX", "Koa", "Lemmy", @@ -86,6 +90,7 @@ proper_nouns = [ "LogTape", "Mastodon", "Mbin", + "Menuverse", "mise", "Misskey", "NestJS", @@ -107,6 +112,7 @@ proper_nouns = [ "Podman Compose", "Prometheus", "RabbitMQ", + "rss2.pub", "Redis", "scrypt", "Scoop", @@ -119,6 +125,7 @@ proper_nouns = [ "Typo Blue", "URI Template", "URI Templates", + "Vegan Home Cooks", "Visual Studio Code", "VitePress", "WebAssembly", diff --git a/AI_POLICY.md b/AI_POLICY.md index 6ad16ed54..6aa52c77c 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -64,10 +64,10 @@ Assisted-by: AGENT_NAME:MODEL_VERSION For example: ~~~~ -Assisted-by: OpenCode:qwen3.6-plus -Assisted-by: Claude Code:claude-sonnet-4-6 -Assisted-by: Gemini CLI:gemini-3.1-pro-preview -Assisted-by: Codex:gpt-5.4 +Assisted-by: OpenCode:qwen3.8-max +Assisted-by: Claude Code:claude-sonnet-5-5 +Assisted-by: Gemini CLI:gemini-3.8-flash +Assisted-by: Codex:gpt-6.1-sol ~~~~ If multiple AI tools were used, include one `Assisted-by` line per tool. diff --git a/CHANGES.md b/CHANGES.md index 6628db86f..ca5be1609 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,435 +3,870 @@ Fedify changelog ================ -Version 2.4.0 +Version 2.5.0 ------------- To be released. ### @fedify/fedify - - Added WebFinger support for [FEP-ef61] portable actors. When an actor - dispatcher returns an actor whose ID is an `ap:` or `ap+ef61:` URI, the - WebFinger response takes the domain of the actor's `acct:` URI from the - first gateway in its `gateways`, as FEP-ef61 requires, and its `self` - link is the actor's compatible identifier based on that gateway, e.g., - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, so - that software without portable ID support can fetch it. A portable - actor without a valid first gateway is not found. Existing - `mapHandle()` and `mapAlias()` callbacks work as before, and WebFinger - resources can now be `ap:` or `ap+ef61:` URIs, which are passed to - `mapAlias()`. To serve the actor itself at its compatible identifier, - register an object dispatcher for it. [[#288], [#837], [#1082]] - - - `Context.lookupObject()` now uses `verifyPortableObjectProof()` as its - new `verifyPortableObject` option by default, so it looks up - [FEP-ef61] portable objects, including portable actors found through - WebFinger, through their gateways and returns them only if they have - valid Object Integrity Proofs made by the DIDs in their IDs. - [[#288], [#837], [#1082]] - - - Actor dispatchers no longer log warnings that a portable actor's ID or - collection URIs do not match the URIs that `Context` builds, such as - `Context.getActorUri()`. [[#288], [#837], [#1082]] - - - Added delivery to [FEP-ef61] portable inboxes through the gateway endpoint, - e.g., `POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox`. - Existing inbox listeners handle such a delivery with the path after the - DID, so the inbox path `/users/{identifier}/inbox` also receives - deliveries to the portable inbox - `ap+ef61://did:key:z6Mk.../users/alice/inbox`. Fedify accepts a delivery - only if the actor dispatcher returns, for the identifier, a portable actor - with the same DID whose `inbox` is the requested portable inbox and whose - `gateways` include this server; otherwise, it responds with - `404 Not Found`. Applications without portable actors are unaffected. - [[#288], [#839], [#1092]] - - - Fedify now forwards an activity delivered to a portable inbox to the same - inbox on the actor's other gateways, as FEP-ef61 recommends, if the - activity is authenticated by its own Object Integrity Proof or Linked Data - Signature. Each activity is forwarded to each gateway at most once, which - requires a `KvStore` that supports `cas()`; with other stores, Fedify logs - a warning and does not forward. Forwarded requests are signed with HTTP - Signatures by this server's gateway key for the actor if - `mapPortableActorId()` maps the recipient to the same portable actor and - the key pairs dispatcher returns an RSA key pair for it, and are sent - unsigned otherwise. An activity is not forwarded back to the gateway that - forwarded it if the delivery is signed by that gateway with its gateway - key for the actor, which costs fetching the actor document from that - gateway. Configure the `origin` option on gateways so that Fedify can - tell which gateway it is regardless of the `Host` header. - [[#288], [#839], [#1092], [#1100], [#1101], [#1104], [#1110]] - - - Added the `Context.getPortableInboxUri()` method, which builds the portable - inbox ID of an actor from the inbox path and a DID. Custom - implementations of the `Context` interface need to implement the new - method. [[#288], [#839], [#1092]] - - - Added the `FederationOptions.portableInboxForwarding` option, whose - `maxTargets`, `ttl`, and `deadline` properties change how many gateways - a delivery is forwarded to (10 by default; `0` turns off forwarding), how - long forwarded activities are remembered (30 days by default), and how - long a delivery waits for forwarding without an outbox queue, and for - identifying the gateway that forwarded it (10 seconds by default). - [[#288], [#839], [#1092], [#1101], [#1110]] - - - Added the `FederationKvPrefixes.portableInboxForwarding` option, the key - prefix for remembering forwarded activities, which defaults to - `["_fedify", "portableInboxForwarding"]`. [[#288], [#839], [#1092]] - - - Added delivery to [FEP-ef61] portable inboxes, i.e., `ap:` and `ap+ef61:` - URIs, to `Context.sendActivity()` and `InboxContext.forwardActivity()`. - They deliver an activity to the compatible identifier of such an inbox - on one of the recipient's gateways, e.g., - `https://gateway.example/.well-known/apgateway/did:key:z6Mk.../actor/inbox`. - The gateways are taken from the recipient's `gateways`, or, if it has no - valid gateway, from the `@gateway` location hints of the inbox URI, and - at most five of them are tried one after another until one accepts the - activity. Previously, such a delivery failed with - `UrlError: Unsupported protocol: ap+ef61:`. A recipient with no gateway - to deliver through is skipped with a warning. [[#288], [#1147], [#1180]] - - - A gateway that fails with a permanent failure status, such as - `404 Not Found`, is not tried again for the activity. If no gateway - accepts the activity, the whole round of gateways counts as one - attempt of the retry policy. - - - The `preferSharedInbox` option is ignored for recipients with portable - inboxes, the `excludeBaseUris` option is compared with the origins of - their gateways, and deliveries to a portable inbox are ordered by the - actor's DID when the `orderingKey` option is given. - - - Queue workers of older Fedify versions deliver queued activities to - portable inboxes only through the first gateway, so upgrade them - before the servers that enqueue deliveries. - - - Added serving of [FEP-ef61] portable collections through the gateway - endpoint, e.g., - `GET /.well-known/apgateway/did:key:z6Mk.../users/alice/outbox`, by the - existing collection dispatchers, including the inbox collection, so that a - portable actor's outbox and other collections are no longer tied to a - single server. Fedify serves such a collection only if the actor whose - identifier is in its path is a portable actor under the requested DID whose - corresponding property, e.g., `outbox`, refers to the collection; otherwise - it responds with `404 Not Found`. The collection and its pages keep the - actor's form of the ID, a portable ID or a compatible identifier, identify - pages with the `cursor` query parameter, and have `attributedTo` set to the - actor. The collection's `authorize()` predicate is applied before anything - is dispatched. The collection itself is served without an Object Integrity - Proof, as FEP-ef61 allows, but it is refused with - `500 Internal Server Error` if it embeds a portable actor, activity, or - object without a valid proof made with a key of the DID in its ID. - Applications without portable actors are unaffected, except that their - actor dispatchers may be called for such requests. - [[#288], [#1111], [#1142]] - - - Added the `Context.getPortableOutboxUri()`, - `Context.getPortableFollowingUri()`, `Context.getPortableFollowersUri()`, - `Context.getPortableLikedUri()`, `Context.getPortableFeaturedUri()`, - `Context.getPortableFeaturedTagsUri()`, and - `Context.getPortableCollectionUri()` methods, which build the portable IDs - of an actor's collections and of custom collections from the same paths - as their non-portable counterparts and a DID. On a `RequestContext`, the - DID defaults to the one in the gateway request. Custom implementations of - the `Context` interface need to implement the new methods. - [[#288], [#1111], [#1142]] - - - Added the `CustomCollectionCallbackSetters.mapPortableOwner()` method and - the `PortableCollectionOwnerMapper` type. A custom collection is served - through the FEP-ef61 gateway endpoint only if this callback maps it to - a portable actor under the requested DID. [[#288], [#1111], [#1142]] - - - Changed the actor dispatcher to warn when a portable actor's collection - property is a portable ID or a compatible identifier that does not match - the one that the corresponding `Context.getPortable*Uri()` method builds, - as the gateway endpoint would not serve the collection. - [[#288], [#1111], [#1142]] - - - Added serving of [FEP-ef61] portable objects through the gateway endpoint, - e.g., `GET /.well-known/apgateway/did:key:z6Mk.../notes/123`. Existing - object dispatchers serve such a request with the path after the DID, so - the object dispatcher registered for `/notes/{id}` serves the portable - object `ap+ef61://did:key:z6Mk.../notes/123`. Fedify responds with - `404 Not Found` unless the dispatcher returns an object with that ID, and - refuses to serve a portable actor, activity, or object that does not have - an Object Integrity Proof made with a key of the DID. As FEP-ef61 - forbids gateways to serve a non-public object to anyone but its intended - audience, and a gateway may store objects that it did not create, Fedify - also responds with `404 Not Found` unless the object is publicly - addressed, i.e., its `to`, `cc`, `bto`, `bcc`, or `audience` has - the public collection, or is an actor. To serve non-public portable - objects to their audience, set an authorization predicate on the object - dispatcher, e.g., one that checks the request with the new - `RequestContext.isSignedByAudience()` method; the predicate then decides - who may retrieve the dispatcher's portable objects, and Fedify responds to - the requests that it allows with `Cache-Control: private`. Applications - that do not serve portable objects are unaffected, except that their - object dispatchers may be called for such requests. - [[#288], [#835], [#1076], [#1153], [#1183]] - - - Added the `RequestContext.isSignedByAudience()` method, which checks - whether the request is signed by an actor in the audience of an object, - comparing [FEP-ef61] portable IDs canonically, and the - `IsSignedByAudienceOptions` interface. Its `isMember` option checks - the members of collections such as followers, which Fedify cannot tell - by itself. Custom implementations of the `RequestContext` interface need - to implement the new method. [[#288], [#1153], [#1183]] - - - Added the `Context.getPortableObjectUri()` method, which builds the - portable ID of an object from its object dispatcher's path and a DID, - and the `RequestContext.portableRequest` property, which tells an object - dispatcher the DID and ID of the requested portable object. Custom - implementations of the `Context` interface need to implement the new - method. [[#288], [#835], [#1076]] - - - Added serving of tombstones of [FEP-ef61] portable objects and actors - through the gateway endpoint with `410 Gone`, as for ordinary objects, - so that other gateways and consumers can tell a deleted portable object - from one that the gateway never stored. A `Tombstone` that an object - dispatcher or the actor dispatcher returns for such a request is served - with `410 Gone` if its ID is the requested portable ID and it has - an Object Integrity Proof made with a key of the DID, and with - `404 Not Found` if it has no proof. Like other portable objects, - a tombstone from an object dispatcher without an authorization predicate - needs public addressing to be served. - [[#288], [#1113], [#1124], [#1153], [#1183]] - - - Added serving of resources addressed by [FEP-ef61] hashlinks, such as - media attached to portable objects, through the gateway endpoint, e.g., - `GET /.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n`. - Register a dispatcher with the new - `Federatable.setHashlinkMediaDispatcher()` method; it receives the parsed - hashlink and its SHA-256 digest as a `HashlinkMediaRequest` object and - returns a `Response`, which Fedify sends as is so that it can be streamed, - or `null` for `404 Not Found`. Fedify responds with `400 Bad Request` to - malformed hashlinks without calling the dispatcher, and does not verify the - response body, so the dispatcher must serve only the resource whose bytes - hash to the requested digest. Applications that do not register the - dispatcher are unaffected. [[#288], [#838]] - - - Added `hashlink_media` to the values of the `fedify.endpoint` metric - attribute, used for requests to the hashlink media endpoint. - [[#288], [#838], [#1080]] - - - Added support for HTTP Signature keys that gateways use on behalf of - [FEP-ef61] portable actors, which Fedify calls *gateway keys*. Register - the new `ActorCallbackSetters.mapPortableActorId()` callback to tell - Fedify which actors are portable, and `Context.getActorKeyPairs()` then - identifies their dispatched key pairs by the actor's compatible identifier - on this server, e.g., - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actors/alice#main-key`, - with the portable actor as their owner, and with the same IDs for their - `CryptographicKey` and `Multikey` forms so that they can be listed in the - actor's `assertionMethods` as FEP-ef61 requires. Requests made on behalf - of such actors, including activity deliveries and signed fetches, are - signed with these keys. The new `PortableActorIdMapper` type describes the - callback. [[#288], [#840], [#1099]] - - - Changed HTTP Signature verification to accept a gateway key of a portable - actor if the actor's document, fetched from the key ID, has a valid Object - Integrity Proof made by the actor's DID, embeds the key in its - `assertionMethod`, and lists the gateway in its `gateways`. Such a key is - owned by the portable actor, so `RequestContext.getSignedKeyOwner()`, - `getKeyOwner()`, and `doesActorOwnKey()` return or match the portable - actor. Given the very key that HTTP Signature verification returned, - they take the actor from the document it verified rather than fetching - and verifying the document again. The document may list the key under - the key ID itself or under the `ap:` URI with the same canonical ID, as - Mitra does, but not under both, nor under a compatible identifier on - another gateway. Previously, no gateway key of a portable actor could be - verified. [[#288], [#840], [#1096], [#1099], [#1123], [#1134], [#1164]] - - - Changed HTTP Signature verification to accept a key of a portable actor - itself whose ID is an `ap:` or `ap+ef61:` URI, e.g., - `ap://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fexample.com#main-key`. - Fedify fetches the actor's document from the gateways in the key ID's - `@gateway` location hints, or, without them, asks the document loader for - the `ap:` URI itself, and accepts the key if the document has a valid - Object Integrity Proof made by the actor's DID, embeds the key under - the `ap:` URI, and has a valid gateway. Since the signer chooses the - hints, only the first three are followed, and all the gateways share - a timeout of ten seconds; a lookup that runs out of time is reported as - a `keyFetchError` without an HTTP status. Such a key is used only for - HTTP Signatures, and is owned by the portable actor like a gateway key. - Keys at `ap:` URIs are never used to make Object Integrity Proofs or - Linked Data Signatures either. [[#288], [#1096], [#1132], [#1134], [#1165]] - - - Changed inboxes to reject activities of portable actors that do not have - a valid Object Integrity Proof made by the actor's DID with - `401 Unauthorized`, even if the request has a valid HTTP Signature or the - activity has a valid Linked Data Signature. [[#288], [#840], [#1099]] - - - Changed `Context.sendActivity()` so that keys whose IDs are FEP-ef61 - compatible identifiers, such as gateway keys, never make Object Integrity - Proofs or Linked Data Signatures; they only sign HTTP requests. Also, - activities of portable actors never get Linked Data Signatures, and have - to have portable IDs of the actors' DIDs, and an unsigned portable - activity is signed only by a key whose ID is a DID URL for its DID, even - if it is the only Ed25519 key. In these cases `sendActivity()` rejects - with a `TypeError` before anything is delivered or queued. Sign - portable activities with `signObject()` beforehand, or pass the DID's key - as an explicit sender key. [[#288], [#840], [#1099]] - - - Changed key lookups so that keys whose IDs are FEP-ef61 compatible - identifiers are cached apart for each purpose, as whether such a key is - valid depends on what it is used for: a gateway key cached for an HTTP - Signature is never used for an Object Integrity Proof or a Linked Data - Signature, and a key rejected for one of them is not rejected for - the others. A failure to fetch such a key, e.g., a network error or - `404 Not Found`, fails every purpose alike and is cached like that of any - other key. A gateway key is cached for an hour at most, and never - beyond the expiration of the proof on the actor's document, since - the actor can drop the gateway from its document at any time. - `fetchKey()` and `fetchKeyDetailed()` do not resolve gateway keys of - portable actors. - [[#288], [#840], [#1095], [#1099], [#1119]] - - - Only the key cache that Fedify's inbox uses caches keys at compatible - identifiers apart for each purpose. A custom `KeyCache` passed to - `verifyRequest()`, `fetchKey()`, and the like caches only the - failures to fetch them. - - - Added support for publishing [FEP-ef61] portable actors and objects whose - IDs are compatible identifiers, such as - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, for - interoperability with software that cannot handle `ap:` URIs. Fedify now - treats such an actor or object as portable wherever it publishes one, as - it already did for those with `ap:` or `ap+ef61:` IDs. Build compatible - identifiers with `toCompatibleEf61Id()` and the first gateway of the - actor before signing the documents; portable IDs remain the recommended - form. [[#288], [#1106], [#1109]] - - - `Context.sendActivity()` now requires an activity of an actor whose - ID is a compatible identifier to have an `ap:` or `ap+ef61:` ID or - a compatible identifier of the actor's DID, and signs an unsigned one - only with the key whose ID is a DID URL for that DID, instead of - sending activities that FEP-ef61 receivers reject. A malformed - compatible actor or activity ID, e.g., one with `@gateway` location - hints, is rejected with a `TypeError`. - - - The FEP-ef61 gateway endpoint now serves an object whose ID is - a compatible identifier of the requested portable object, on any - gateway, instead of responding with `404 Not Found`. - - - Portable inboxes now accept deliveries on behalf of actors whose IDs - or inboxes are compatible identifiers, and forward each activity to - the other gateways at most once whether its ID is an `ap:` URI or - a compatible identifier. - - - WebFinger now takes the domain of such an actor's address from its - first gateway, and uses its ID as the `self` link. - - - Actor dispatchers no longer warn that such an actor's ID or collection - URIs do not match the URIs that `Context` builds, but warn if its ID - is a malformed compatible identifier or is not on its first gateway, - as FEP-ef61 requires. - - - Fedify now warns when a served object, or a sent activity and its - embedded objects, use compatible identifiers away from the owner's - first gateway when that gateway is known locally. The warning does - not change what is served or sent. [[#1155], [#1188]] - - - Added the `Context.getPortableActorUri()` method, which builds the - [FEP-ef61] portable ID of an actor from its actor dispatcher's path and - a DID, e.g., `ap+ef61://did:key:z6Mk.../users/alice`. Like - `Context.getPortableObjectUri()`, the DID can be omitted while handling - a gateway request, in which case the DID in the request path is used. - Custom implementations of the `Context` interface need to implement the - new method. [[#288], [#841], [#1114]] - - - The actor dispatcher now serves portable actors through the FEP-ef61 - gateway endpoint, e.g., - `GET /.well-known/apgateway/did:key:z6Mk.../users/alice`, the same way - object dispatchers serve portable objects, so a portable actor whose ID - `Context.getPortableActorUri()` builds can be fetched at its compatible - identifier without registering an extra object dispatcher. The actor is - served only if its ID canonically equals the requested portable ID and it - has an Object Integrity Proof made with a key of the DID, and - `RequestContext.portableRequest` is set for such requests. Applications - that do not have portable actors are unaffected, except that their actor - dispatchers may be called for such requests. [[#288], [#841], [#1114]] - - - `Context.getPortableActorUri()`, `Context.getPortableObjectUri()`, and - `Context.getPortableInboxUri()` now throw a `TypeError` if the DID is - a `did:key` DID that is not encoded in base58-btc, as FEP-ef61 requires, - so that the same key does not yield two different portable IDs. Use - `exportDidKey()` from `@fedify/vocab-runtime` to make such DIDs. - Likewise, the gateway endpoint responds with `400 Bad Request` to - requests whose path has such a DID. [[#288], [#841], [#1114]] - - - Added the `Context.verifyPortableObject` property, the [FEP-ef61] - portable object policy of the context: `verifyPortableObject()` with the - context's document loader and context loader. Since it has the same name - as the `verifyPortableObject` option of property accessors, passing - a context as their options, e.g., `await create.getObject(ctx)`, now - verifies portable objects that they dereference. Previously, - `create.getObject(ctx)` on an activity whose ID is a compatible - identifier, such as one from tootik, fetched the object it refers to as - an ordinary HTTP(S) URL and trusted it because of its origin. Note that - this also applies to objects with ordinary HTTP(S) IDs: their references - to compatible identifiers are now verified as portable objects when you - pass a context. [[#288], [#1107], [#1120]] - - - Inboxes now parse received activities with `Context.verifyPortableObject` - as their default verifier, so accessors called without options in inbox - listeners, e.g., `await create.getObject()`, verify portable objects - too, including for queued activities and for activities authenticated - by Object Integrity Proofs. The activities passed to the - `onOutboxError` callback have the default as well. - [[#288], [#1107], [#1120]] - - - Changed `Context.lookupObject()` to use `Context.verifyPortableObject` - instead of `verifyPortableObjectProof()` as the default - `verifyPortableObject` option, so that it also accepts an unsecured - portable collection served by a gateway that its owner lists, as - accessors do. Pass `verifyPortableObject: verifyPortableObjectProof` to - accept only objects with proofs. The returned object uses the policy by - default for its property accessors. `Context.traverseCollection()` also - uses `Context.verifyPortableObject` by default. [[#288], [#1107], [#1120]] - - - Added the `verifyPortableObject` option to `VerifyObjectOptions`. It is - not used to verify the given object; the returned object uses it by - default for its property accessors. [[#288], [#1107], [#1120]] - - - Added the `portable` option to `Context.parseUri()`, and the - `ParseUriOptions` interface. With `{ portable: true }`, the method also - recognizes [FEP-ef61] portable IDs, e.g., - `ap+ef61://did:key:z6Mk.../users/alice`, and their compatible identifiers - on any gateway by the same paths through which the gateway endpoint serves - them, so that an inbox listener can tell that an activity is about one of - its portable actors, objects, or collections. The result then has the DID - of the ID in its new `authority` property. Since anyone can make - a portable ID with the same path under another DID, check that - `authority` is the DID that you store for the actor or object before - acting on the result. Without the option, `parseUri()` behaves as - before. [[#288], [#1143], [#1145]] - - - Added validation of the `gateways` of [FEP-ef61] portable actors, which - FEP-ef61 requires to be a non-empty list of HTTP(S) URIs with an empty - path, query, and fragment. `verifyPortableObjectProof()` and - `verifyPortableObject()` now reject a portable actor whose `gateways` is - missing or empty, or has any other item, with the new `invalidGateways` - reason, even if its proofs are valid, so such an actor is no longer looked - up, accepted in an inbox, or served through the gateway endpoint. If an - actor dispatcher returns a portable actor with such `gateways`, Fedify - logs a warning. An incoming activity that embeds an actor with a gateway - that has a path, query, or fragment is now rejected with - `400 Bad Request` as malformed, instead of failing with an error. - [[#288], [#1148], [#1178]] - - - Added `verifyPortableObject()`, which applies the [FEP-ef61] trust policy - to portable objects fetched through gateways. It verifies Object - Integrity Proofs like `verifyPortableObjectProof()`, and also accepts - a portable collection or collection page without a proof if it was - served by a gateway listed in the `gateways` of the actor that owns it. - The owner must be the actor whose `inbox`, `outbox`, `followers`, - `following`, or `liked` it is, as confirmed by the owner's signed actor - document. Pass it as the `verifyPortableObject` option of property - accessors and `traverseCollection()` instead of - `verifyPortableObjectProof()` to read portable actors' collections, such - as their outboxes. The new `VerifyPortableObjectOptions`, - `VerifyPortableObjectResult`, and `VerifyPortableObjectFailureReason` - types describe it. [[#288], [#836], [#1084]] + - Added `InboxContext.authentication`, which tells inbox listeners who Fedify + authenticated for an incoming activity independently of its declared + `actor`: the signers of the HTTP request, the signers of the activity's + Linked Data Signature or Object Integrity Proofs, and the activity as Fedify + interpreted it while authenticating it. A valid signature does not + authorize an activity by itself; use the new `isActorAuthenticated()` + function to check whether a specific actor was authenticated, e.g., the + owner of an object the activity acts on. The new `InboxPrincipal` and + `InboxAuthenticationEvidence` types describe the evidence. + [[#1290], [#1299]] + - Added the `allowActorless` option to `InboxListenerSetters.on()`, so that + activities declaring no `actor` at all, such as the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends, can reach a listener. Such an + activity is dispatched only if Fedify authenticated a principal for it and + its `id` has the same origin as that principal; the listener must authorize + it with `InboxContext.authentication`. Without the option, these + activities are still rejected with `400 Bad Request`. An activity whose + `actor` cannot be read, such as an anonymous actor, is never treated as + having no actor. Rejected deliveries are reported with the new + `noAuthenticatedPrincipal` reason in `onRequestFinished` reports. + [[#1289], [#1290], [#1299]] + - Changed the inbox to interpret each incoming activity with one set of + JSON-LD context documents throughout signature verification and dispatch, + and to keep that interpretation, along with the authentication evidence, + in queued inbox messages. Queue workers and their retries no longer load + remote JSON-LD contexts again for such messages, so a remote context that + changes in the meantime cannot make a listener see an activity other than + the one that was authenticated. An activity using a context that cannot + be kept this way, such as one a custom context loader marks as `static`, + is processed right away instead of being queued. Messages queued by + older versions are processed as before. [[#1290], [#1299]] + - Added the `MessageQueue.maxMessageSize` property. When an incoming + activity would make an inbox message larger than the queue can hold, it is + now processed right away instead of being enqueued. [[#1290], [#1299]] + - Changed inbox queue workers to deduplicate activities with the configured + `idempotency` strategy, as direct processing already did, instead of always + deduplicating them per origin. Activities recorded by workers of older + versions are still recognized. [[#1290], [#1299]] + - Added the `activityOriginMismatch` reason to rejected `InboxAuthentication` + decisions in `onRequestFinished` reports. Inbox deliveries rejected + because the activity's ID does not have the same origin as its actors now + report this reason, along with the activity ID and actor IDs, instead of + `verificationFailed`. The `activitypub.verification.failure_reason` span + attribute uses the same value. \[[GHSA-4qhx-hhwf-8685]] + - Changed compound portable-object verification to check each map's proof + policy before resolving its key or verifying its signature, avoiding work + for maps that will be rejected. Policy-rejected maps now appear in + `onRequestFinished` verification reports with their policy failure and no + signature checks. [[#1177], [#1264]] + - Fixed log entries emitted during an individual outbound activity delivery + or inbound activity processing carrying the enclosing HTTP request's or + queue worker's `traceId`/`spanId` instead of the delivery or processing + operation's own. Warning and error logs now match the `traceId`/`spanId` + of the `TraceActivityRecord` that operation produces (as exposed by + `@fedify/fedify/otel`), including when the operation runs inside a + background queue worker, which makes it possible to correlate a failure + log with the specific activity it belongs to. + [[#1030], [#1205] by Yujin Jung\] + +[FEP-7aa9]: https://w3id.org/fep/7aa9 +[GHSA-4qhx-hhwf-8685]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-4qhx-hhwf-8685 +[#1030]: https://github.com/fedify-dev/fedify/issues/1030 +[#1177]: https://github.com/fedify-dev/fedify/issues/1177 +[#1205]: https://github.com/fedify-dev/fedify/pull/1205 +[#1264]: https://github.com/fedify-dev/fedify/pull/1264 +[#1289]: https://github.com/fedify-dev/fedify/issues/1289 +[#1290]: https://github.com/fedify-dev/fedify/issues/1290 +[#1299]: https://github.com/fedify-dev/fedify/pull/1299 + +### @fedify/cli + + - Changed debug logs for suppressed recursive lookup errors to preserve the + original error as a cause alongside the failed target. [[#901], [#1265]] + - Changed the `fedify nodeinfo` favicon selector to pick a usable bitmap + icon more often. It now skips SVG icons declared with + `type="image/svg+xml"`, not just those with a `.svg` URL, and it considers + every declared size instead of only the first, so an icon offering a large + size is no longer discarded because its smallest size is too small. + [[#893], [#1187] by Lee Jeongmin\] + - Changed the `fedify webfinger` command's error message when a lookup + fails. It now explains why—resource not found, a network error, a + malformed response, or an unexpected server error—instead of always + reporting the resource as not found. [[#897], [#1296] by Jae-Hyuk-Jang\] + +[#893]: https://github.com/fedify-dev/fedify/issues/893 +[#897]: https://github.com/fedify-dev/fedify/issues/897 +[#901]: https://github.com/fedify-dev/fedify/issues/901 +[#1187]: https://github.com/fedify-dev/fedify/pull/1187 +[#1265]: https://github.com/fedify-dev/fedify/pull/1265 +[#1296]: https://github.com/fedify-dev/fedify/pull/1296 + +### @fedify/debugger + + - Added filtering controls to the debug dashboard. The traces list can + now be narrowed down by activity type, and a trace's log table can be + narrowed down by category, level, or a free-text search of the message. + This makes it easier to find one failed activity or one noisy log + category once a federated app has produced more than a few traces. + [[#896], [#1204] by Jae-Hyuk-Jang\] + +[#896]: https://github.com/fedify-dev/fedify/issues/896 +[#1204]: https://github.com/fedify-dev/fedify/pull/1204 + +### @fedify/denokv + + - `DenoKvMessageQueue` now declares the `maxMessageSize` of Deno KV, so that + an incoming activity too large for Deno KV is processed immediately + instead of failing to be enqueued. [[#1290], [#1299]] + +### @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. + + - `featureInteraction.verifyRequest()` can now verify the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends without an `actor`. Pass the + inbox listener's `InboxContext.authentication` as the new `authentication` + option; the requester is then the owner of the featured collection, who + must have been authenticated. [[#1289], [#1290], [#1299]] + + - Changed `featureInteraction.verifyRequest()` to always fetch the featured + collection from its ID and to take its owner from that document only: an + ordinary collection must be served from the origin of its ID, a portable + one must satisfy the FEP-ef61 policy, and it must have exactly one + `attributedTo` value. A collection embedded in the request, or given as + `resolvedInteractingObject`, is no longer trusted, because anyone could + attribute someone else's collection to themselves that way. A request + without an actor is no longer accepted on the strength of the collection's + `attributedTo` alone; without `authentication`, it fails with + `missingActor`. New failure types `credentialsInUrl`, + `invalidInteractingObject`, `ambiguousRequest`, and + `unauthenticatedRequester` describe the new checks. [[#1290], [#1299]] + +[#1206]: https://github.com/fedify-dev/fedify/issues/1206 +[#1245]: https://github.com/fedify-dev/fedify/pull/1245 + +### @fedify/testing + + - Mock inbox contexts made by `createInboxContext()` now have the + `authentication` property, which can be given as an option and defaults + to evidence that nothing was authenticated. [[#1290], [#1299]] + +### @fedify/vocab + + - Added vocabulary support for the [FEP-6757] draft, which marks + ActivityPub content with explicit copyright license URIs. + [[#1212], [#1241]] + + - Added `Object.licenses` and `Link.licenses` properties for the + alternative licenses of an object or a link. They write the Dublin + Core `license` property and also read the Schema.org and Creative + Commons `license` properties when it is absent. Values that are not + absolute URIs, such as license names or SPDX short identifiers, are + skipped. + - Added `preferredLicense` property to `Application`, `Group`, + `Organization`, `Person`, and `Service` for the license an actor + prefers for its new objects. It is not a fallback for objects without + their own license. + - Fedify does not fill in a default license for objects without license + metadata and never fetches a license URI. + +[FEP-6757]: https://w3id.org/fep/6757 +[#1212]: https://github.com/fedify-dev/fedify/issues/1212 +[#1241]: https://github.com/fedify-dev/fedify/pull/1241 + +### @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 + applications can normalize such tags before passing them to it. + [[#1229], [#1231]] + +[#1229]: https://github.com/fedify-dev/fedify/issues/1229 +[#1231]: https://github.com/fedify-dev/fedify/pull/1231 + +### @fedify/vocab-tools + + - Added non-functional `redundantProperties` support with ordered fallback + to the first non-empty value set, preserving lists and graphs without + merging synonyms. Added `redundantPropertiesWrite: canonical` to write + only the canonical property while accepting synonyms. Fixed serialization + of synonyms without a compact name and validation of synonym definitions. + [[#1210], [#1215]] + - Added the `fedify:absoluteIri` range for URLs written as IRI references + that also accept string literals when read. Unlike + `http://www.w3.org/2001/XMLSchema#anyURI`, it never resolves a value + against the object's ID, and skips values that are not absolute instead of + failing the whole object. [[#1212], [#1241]] + - Changed `extraContext` to add its context when the property is populated + even if the default context defines a matching prefix, such as `dc` in + `https://w3id.org/identity/v1`. Previously, such a property compacted to + a prefixed name like `dc:license` without the extra context. + [[#1212], [#1241]] + +[#1210]: https://github.com/fedify-dev/fedify/issues/1210 +[#1215]: https://github.com/fedify-dev/fedify/pull/1215 + +### @fedify/webfinger + + - Added a `withDiagnostics` option to `lookupWebFinger()`. When turned on, + the function returns an object describing why the lookup failed—a + network error, a malformed response, an unexpected server error, or the + resource genuinely not existing—instead of returning `null` for every + failure. `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296] by Jae-Hyuk-Jang\] + + +Version 2.4.4 +------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` accepting URLs with a username + or password. Such URLs now throw `UrlError` before fetching on all + runtimes, including redirect and alternate document targets, regardless + of `allowPrivateAddress`. + [[#1294], [#1295]] + - Fixed key ownership checks accepting another actor's document when + resolving an actor ID. When an actor ID redirected to, or directly + served, the document of a different actor, keys listed by that other + actor were attributed to the requested one, so an attacker whose actor + document was reached through a victim's actor ID could sign activities + that passed authentication as the victim's. This affected HTTP + Signatures, Linked Data Signatures, and Object Integrity Proofs. Fetched + actor documents are now accepted only when their `id` is exactly the + requested actor ID. \[[GHSA-8863-8468-7hq7]] + - As a consequence of the above fix, an actor whose ID redirects to + a document with a different `id`, even on the same origin and for the + same account, can no longer have its keys confirmed through that + redirect. Servers that normalize actor URLs should use the canonical + actor ID, which responds without redirecting, as the `owner` or + `controller` of their keys and as the `actor` of their activities. + [[GHSA-8863-8468-7hq7]] + - The built-in `KvStore`-backed key cache now uses a new generation, so + keys cached under the weaker check are fetched and verified again after + upgrading. Applications with a custom `KeyCache` implementation should + clear it once when upgrading. \[[GHSA-8863-8468-7hq7]] + +[GHSA-8863-8468-7hq7]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-8863-8468-7hq7 +[#1294]: https://github.com/fedify-dev/fedify/issues/1294 +[#1295]: https://github.com/fedify-dev/fedify/pull/1295 + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` accepting URLs with a username or password. + Such URLs now throw `UrlError` before fetching on all runtimes, including + redirect and alternate document targets, regardless of + `allowPrivateAddress`. + [[#1294], [#1295]] + + +Version 2.4.3 +------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. + [[#1292], [#1293]] + - Fixed inboxes accepting signed activities whose IDs had a different origin + from their actors. Such deliveries now receive `401 Unauthorized` before + they can supply trusted embedded objects or reserve the genuine activity's + idempotency entry. Queued and manually routed activities apply the same + origin check. \[[GHSA-4qhx-hhwf-8685]] + +[#1292]: https://github.com/fedify-dev/fedify/issues/1292 +[#1293]: https://github.com/fedify-dev/fedify/pull/1293 + +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +[#1277]: https://github.com/fedify-dev/fedify/issues/1277 +[#1280]: https://github.com/fedify-dev/fedify/pull/1280 + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nuxt + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when no + application route or Nuxt page could serve a representation. Renderer + 404s are now converted back to 406 before Nitro's error handler runs, + so these responses do not reach Nitro's error handler or error hooks. + Deliberate 404s from matched server routes are preserved. + [[#1278], [#1279]] + +[#1278]: https://github.com/fedify-dev/fedify/issues/1278 +[#1279]: https://github.com/fedify-dev/fedify/pull/1279 + +### @fedify/solidstart + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when an + application returned a `Response` with status 404, returned no response, + or threw a 404 error. Responses with other statuses are preserved. The + 406 is restored for buffered 404 responses before headers are sent; + responses whose headers have already been sent cannot be changed. Thrown + errors still reach Nitro's error hooks. + [[#1278], [#1279]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. + [[#1292], [#1293]] + + +Version 2.4.2 +------------- + +Released on October 9, 2026. + +### @fedify/fedify + + - Fixed signature verification throwing an exception when a remote actor + supplied a malformed JSON-LD context or its context could not be fetched. + These requesters are now treated as unverifiable. + [[#1267], [#1270]] + +[#1267]: https://github.com/fedify-dev/fedify/issues/1267 +[#1270]: https://github.com/fedify-dev/fedify/pull/1270 + +### @fedify/backfill + + - Fixed conversation backfill skipping posts in paginated context and replies + collections. Backfill now follows the first and subsequent pages while + respecting traversal limits, and retains posts already found if a page + cannot be loaded. + [[#1248], [#1256] by Jiwon Kwon\] + - Fixed conversation backfill stopping when the context collection loader + failed. Failed context loads are now skipped so later configured strategies + can still find accessible posts. Cancellation and interval configuration + errors continue to propagate. + [[#1249], [#1258] by Jiwon Kwon\] + +[#1248]: https://github.com/fedify-dev/fedify/issues/1248 +[#1249]: https://github.com/fedify-dev/fedify/issues/1249 +[#1256]: https://github.com/fedify-dev/fedify/pull/1256 +[#1258]: https://github.com/fedify-dev/fedify/pull/1258 + +### @fedify/cfworkers + + - Fixed npm refusing to install `@fedify/cfworkers` alongside + `@cloudflare/workers-types` 5.x, which recent versions of Wrangler + require, unless `--legacy-peer-deps` or an override was used. The peer + dependency on `@cloudflare/workers-types` now accepts both 4.x and 5.x, + so you can drop such workarounds. + [[#1255], [#1260]] + +[#1255]: https://github.com/fedify-dev/fedify/issues/1255 +[#1260]: https://github.com/fedify-dev/fedify/pull/1260 + +### @fedify/express + + - Fixed asynchronous `contextDataFactory` and `federation.fetch()` failures + being left as unhandled rejections in the Express integration. Errors + that occur before Fedify passes the request to the next middleware now + reach Express error-handling middleware, so applications can send their + usual error response. + [[#1244], [#1261]] + +[#1244]: https://github.com/fedify-dev/fedify/issues/1244 +[#1261]: https://github.com/fedify-dev/fedify/pull/1261 + +### @fedify/init + + - Migrated the initialization template script to SvelteKit version 3, by + replacing the `$lib` syntax to `#lib`. [[#1222], [#1224] by Jungmin Yoon\] + +[#1222]: https://github.com/fedify-dev/fedify/issues/1222 +[#1224]: https://github.com/fedify-dev/fedify/issues/1224 + +### @fedify/postgres + + - Fixed `PostgresMessageQueue` remaining unusable after a transient + initialization failure. Later calls now retry initialization, so the same + queue instance can recover when the database becomes available again. + [[#1268], [#1271]] + +[#1268]: https://github.com/fedify-dev/fedify/issues/1268 +[#1271]: https://github.com/fedify-dev/fedify/pull/1271 + +### @fedify/sveltekit + + - Fixed the hook types returned by `fedifyHook()` to support both SvelteKit 2 + and 3, so SvelteKit 3 applications can type-check the integration. + [[#1222], [#1224]] + + +Version 2.4.1 +------------- + +Released on October 5, 2026. + +### @fedify/fedify + + - Fixed cached signing-key fetch failures losing their error types, URLs, + failure reasons, and cause details, so applications can classify failures + consistently before and after caching. + [[#1168], [#1237] by Junghoon Ban\] + +[#1168]: https://github.com/fedify-dev/fedify/issues/1168 +[#1237]: https://github.com/fedify-dev/fedify/pull/1237 + +### @fedify/vocab + + - Fixed language-tagged strings and `Link.hreflang` values with extended + language subtags being dropped or rejected. Tags such as `zh-YUE`, which + Mastodon uses for Cantonese posts, are now accepted and parsed into their + canonical form (`yue`). Unmodified objects still re-emit the original + tag through the cached JSON-LD document, but objects serialized afresh + use the canonical tag. + [[#1229], [#1231]] + - Fixed shared property arrays in vocabulary objects so dereferencing a + clone no longer changes its source's properties or serialization. + Constructors and `clone()` also copy supplied plural-value arrays, + allowing frozen arrays and preventing changes to the caller's arrays. + Nested objects and URLs retain their identity. + [[#1207], [#1208]] + +[#1207]: https://github.com/fedify-dev/fedify/issues/1207 +[#1208]: https://github.com/fedify-dev/fedify/pull/1208 + +### @fedify/vocab-runtime + + - Added the [FEP-6757] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents from being parsed or verified. + [[#1211], [#1216]] + - Fixed the `LanguageString` constructor throwing a `RangeError` for + language tags with extended language subtags, such as `zh-YUE`. It now + stores their canonical form (`yue`) as its `locale`. + [[#1229], [#1231]] + - Fixed the preloaded Mastodon context treating `attributionDomains` values + as IRIs instead of plain domain name strings. Values now expand as string + literals and remain arrays when compacted, matching Mastodon's context. + [[#1233], [#1235]] + +[#1211]: https://github.com/fedify-dev/fedify/issues/1211 +[#1216]: https://github.com/fedify-dev/fedify/pull/1216 +[#1233]: https://github.com/fedify-dev/fedify/issues/1233 +[#1235]: https://github.com/fedify-dev/fedify/pull/1235 + +### @fedify/vocab-tools + + - Fixed generated constructors and `clone()` methods to copy property + arrays, preventing a clone's remote lookups from changing its source and + preserving arrays supplied by callers. + [[#1207], [#1208]] + - Fixed generated vocabulary code rejecting language tags with extended + language subtags, such as `zh-YUE`, in language-tagged strings and + `fedify:langTag` values. Code generated by this version requires + a release of *@fedify/vocab-runtime* that includes this fix. + [[#1229], [#1231]] + + +Version 2.4.0 +------------- + +Released on October 1, 2026. + +### @fedify/fedify + + - Added `onRequestFinished()` to observe every inbox delivery, including + rejected requests and preparation errors, with signature/proof checks, + actual verification keys, the final authentication decision, and the + processing outcome. The callback is awaited independently of trace + sampling, and its errors do not change delivery results. [[#1191], [#1201]] + + - Added support for [FEP-ef61] portable objects, whose IDs are `ap:` or + `ap+ef61:` URIs with a DID instead of a host, e.g., + `ap://did:key:z6Mk.../actor`. A Fedify server can now act as a gateway for + portable actors: it serves their actor documents, objects, collections, and + hashlink media, accepts deliveries to their inboxes, and sends their + activities. It can also consume the portable objects of others. The new + [*Portable objects*] chapter of the manual walks through + running portable actors, and describes the FEP-ef61 profile that Fedify + supports, including where it deliberately differs from the FEP and what it + does not implement. Applications without portable actors are unaffected, + except that their dispatchers may be called for requests to the gateway + endpoint, */.well-known/apgateway/*, and that `Context.sendActivity()` may + reject activities that embed others' portable objects (see below). + [[#288], [#1151], [#1198]] + + - Added `verifyPortableObjectProof()`, which enforces the FEP-ef61 proof + policy: a portable actor, activity, or object needs an [FEP-8b32] + Object Integrity Proof whose `verificationMethod` is a DID URL of + the DID in its ID, and a portable actor needs a non-empty `gateways` + list of HTTP(S) origins. A document whose ID is a compatible + identifier, e.g., + `https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor`, is + verified against the DID in it. Its detailed result distinguishes + documents outside the policy, unsecured collections, missing or invalid + proofs, invalid gateways, unsupported verification methods, and DID + mismatches. [[#832], [#968], [#1093], [#1105], [#1148], [#1178]] + + - Added `verifyPortableObject()`, which verifies proofs like + `verifyPortableObjectProof()`, and also accepts an actor's `inbox`, + `outbox`, `followers`, `following`, or `liked` collection without + a proof if a gateway that the actor lists served it, as FEP-ef61 + allows. The `VerifyPortableObjectOptions`, + `VerifyPortableObjectResult`, and `VerifyPortableObjectFailureReason` + types describe it. [[#836], [#1084], [#1093], [#1105]] + + - Added the `Context.verifyPortableObject` property, which is + `verifyPortableObject()` with the context's loaders. Since it has + the same name as the option of property accessors, passing a context + as their options, e.g., `await create.getObject(ctx)`, verifies + portable objects. Inboxes use it as the default verifier of received + activities, as do `Context.lookupObject()`, + `Context.traverseCollection()`, and the `onOutboxError` callback. + Added the `verifyPortableObject` option to `VerifyObjectOptions`. + [[#1107], [#1120]] + + - `verifyProof()` now resolves Ed25519 `did:key` verification methods, + e.g., `did:key:z6Mk...#z6Mk...`, locally without fetching them, and + `verifyObject()` accepts a proof made by a DID as authenticating + an actor or attribution whose portable ID or compatible identifier + has that DID. [[#827], [#829], [#915], [#926], [#1093], [#1105]] + + - Added the `Context.getPortableActorUri()`, + `Context.getPortableObjectUri()`, `Context.getPortableInboxUri()`, + `Context.getPortableOutboxUri()`, `Context.getPortableFollowingUri()`, + `Context.getPortableFollowersUri()`, `Context.getPortableLikedUri()`, + `Context.getPortableFeaturedUri()`, + `Context.getPortableFeaturedTagsUri()`, and + `Context.getPortableCollectionUri()` methods, which build portable IDs + from the paths of the corresponding dispatchers and a DID. Custom + implementations of the `Context` interface need to implement them. + [[#835], [#839], [#841], [#1076], [#1092], [#1111], [#1114], [#1142]] + They throw a `TypeError` for a `did:key` DID that is not encoded in + base58-btc, as FEP-ef61 requires, or for an identifier that has + a `.` or `..` path segment. [[#841], [#1114], [#1154], [#1186]] + + - Added the `portable` option to `Context.parseUri()` and + the `ParseUriOptions` interface. With `{ portable: true }`, + the method also recognizes portable IDs and their compatible + identifiers, and the result has the DID in its new `authority` + property. [[#1143], [#1145]] + + - Object dispatchers and the actor dispatcher now serve portable objects + and actors through the gateway endpoint, e.g., + `GET /.well-known/apgateway/did:key:z6Mk.../notes/123`, with the path + after the DID. An object is served only if its ID canonically equals + the requested portable ID and it has a proof made with a key of + the DID. A non-public object is served only if the dispatcher has + an authorization predicate, as FEP-ef61 forbids gateways to serve it + to anyone but its audience. Signed tombstones are served with + `410 Gone`. Added the `RequestContext.portableRequest` property, + which tells the dispatcher the requested DID and ID. + [[#835], [#841], [#1076], [#1113], [#1114], [#1124], [#1153], [#1183]] + + - Added the `RequestContext.isSignedByAudience()` method and + the `IsSignedByAudienceOptions` interface, which check whether + a request is signed by an actor in the audience of an object, e.g., + in the authorization predicate of an object dispatcher. Custom + implementations of the `RequestContext` interface need to implement + the method. [[#1153], [#1183]] + + - Collection dispatchers now serve the collections of portable actors + through the gateway endpoint, e.g., + `GET /.well-known/apgateway/did:key:z6Mk.../users/alice/outbox`, if + the actor is a portable actor under the requested DID whose + corresponding property refers to the collection. A collection that + is not paginated always has `totalItems`, so that consumers can tell + an empty one from other objects. Added + the `CustomCollectionCallbackSetters.mapPortableOwner()` method and + the `PortableCollectionOwnerMapper` type, which tie a custom + collection to its portable owner. [[#1111], [#1142]] + + - Added `Federatable.setHashlinkMediaDispatcher()` and + the `HashlinkMediaRequest` interface, which serve resources that + portable objects refer to with SHA-256 hashlinks, e.g., + `GET /.well-known/apgateway/hl:zQm...`. Fedify does not verify the + dispatcher's response against the digest. Added `hashlink_media` to + the values of the `fedify.endpoint` metric attribute. + [[#838], [#1080]] + + - Inbox listeners now accept deliveries to portable inboxes through + the gateway endpoint, e.g., + `POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox`, if + the actor dispatcher returns a portable actor whose `inbox` is + the requested inbox and whose `gateways` include this server. + [[#839], [#1092]] + + - Fedify now forwards an activity delivered to a portable inbox to + the actor's other gateways at most once, as FEP-ef61 recommends, if + the activity is authenticated by its own proof or Linked Data + Signature and the `KvStore` supports `cas()`. Forwarded requests are + signed with this server's RSA gateway key for the actor if the key + pairs dispatcher returns one, and are sent unsigned otherwise. An + activity is not forwarded back to the gateway that forwarded it if + Fedify can identify that gateway by its gateway key signature on the + delivery. Added the `FederationOptions.portableInboxForwarding` and + `FederationKvPrefixes.portableInboxForwarding` options. + [[#839], [#1092], [#1100], [#1101], [#1104], [#1110]] + + - `Context.sendActivity()` and `InboxContext.forwardActivity()` now + deliver to portable inboxes through the recipient's gateways, or else + the `@gateway` location hints of the inbox, trying at most five of + them until one accepts the activity. Previously, such a delivery + failed with `UrlError: Unsupported protocol: ap+ef61:`. Queue + workers of older Fedify versions deliver such queued activities only + through the first gateway, so upgrade them before the servers that + enqueue deliveries. [[#1147], [#1180]] + + - Added the `ActorCallbackSetters.mapPortableActorId()` method and + the `PortableActorIdMapper` type. For an actor that the callback + maps to a portable ID, `Context.getActorKeyPairs()` identifies its key + pairs by the actor's compatible identifier on this server, e.g., + `https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice#main-key`, + so that they serve as this server's *gateway keys* for the actor, which + sign HTTP requests made on behalf of the actor, but never make Object + Integrity Proofs or Linked Data Signatures. [[#840], [#1099]] + + - HTTP Signature verification now accepts a gateway key of a portable + actor if the actor document at the key ID has a valid proof by + the actor's DID, embeds the key in its `assertionMethod`, or else in + its `publicKey`, and lists the gateway in its `gateways`. Such a key + is owned by the portable actor, so + `RequestContext.getSignedKeyOwner()`, `getKeyOwner()`, and + `doesActorOwnKey()` return or match the actor. Keys at compatible + identifiers are cached apart for each purpose, for an hour at most. + [[#840], [#1095], [#1099], [#1105], [#1119], [#1123], [#1164]] + A key at an `ap:` key ID is accepted likewise if an actor document + fetched through the key ID's location hints vouches for it. + [[#1096], [#1132], [#1134], [#1165]] + + - Inboxes accept an activity of a portable actor, or an activity with + a portable ID, only if it has a valid proof made by the DID of that + ID; an HTTP Signature or a Linked Data Signature does not + authenticate it, and such an activity is rejected with + `401 Unauthorized`. [[#840], [#1099]] + + - Inboxes independently verify each portable actor, activity, and + object embedded in a compound document, e.g., the `Note` in + a `Create`, against its own proof before dispatch, following Fedify's + interim *map-local* profile, since neither FEP-8b32 nor Verifiable + Credential Data Integrity defines the boundaries of embedded proofs + yet. A valid outer proof does not authenticate an unsigned or invalid + embedded portable object. Documents with proof sets, or that exceed + the traversal limits, are rejected. A key embedded in a verified + portable actor needs no proof of its own if the key's ID is the actor's + ID plus a fragment. This profile may change in Fedify 3.0. + [[#938], [#1041], [#1094], [#1102], [#1133], [#1138]] + + - `Context.sendActivity()` gives an activity that contains portable + objects at most one proof: an activity that already has one is sent + as is, and a portable activity is signed only by the key whose ID is + a DID URL for its DID. An activity of a portable actor has to have + a portable ID or a compatible identifier of the actor's DID, and never + gets a Linked Data Signature. Otherwise, `sendActivity()` rejects with + a `TypeError` before anything is delivered or queued. So does a + non-portable activity that embeds portable objects, including ones with + compatible identifiers, when several Ed25519 keys are available, and an + activity with portable objects in which any map carries a proof set, or + an embedded map carries a proof but not its own `@context`, e.g., + a received signed `Follow` embedded in an `Accept`, which Fedify + inboxes would reject; refer to such an object by its ID instead. + Sign portable activities with `signObject()` beforehand, or pass the + DID's key as an explicit sender key. + [[#840], [#1041], [#1045], [#1073], [#1099]] + + - Your portable actors and objects may have compatible identifiers as + their IDs, e.g., + `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, + for interoperability with software that cannot handle portable IDs. + Fedify treats them as portable wherever it serves or sends them, and + warns if such an ID is malformed or is not on the owner's first + gateway, as FEP-ef61 requires. Build them with `toCompatibleEf61Id()` + before signing the documents. [[#1106], [#1109], [#1155], [#1188]] + + - The WebFinger endpoint now serves portable actors: the domain of + the actor's address comes from the first gateway in its `gateways`, + and its `self` link is its compatible identifier on that gateway. + WebFinger resources can be portable IDs, which are passed to + `mapAlias()`. `Context.lookupObject()` resolves the handles of + portable actors on other servers. [[#837], [#1082], [#1106], [#1109]] + + - Fedify logs a warning if an actor dispatcher returns a portable actor + whose `gateways` are invalid, or whose collections are not the + portable IDs or compatible identifiers that the corresponding + `Context.getPortable*Uri()` methods build, instead of warning that + a portable actor's ID or collections do not match the URIs that + `Context.getActorUri()` and the like build. + [[#837], [#1082], [#1111], [#1142], [#1148], [#1178]] + + - `Context.routeActivity()` routes a portable activity that has no + valid proof only if all of its actors have the same DID as + the activity, comparing [FEP-fe34] origins, where the origin of + a portable ID or a compatible identifier is its DID. [[#1146], [#1171]] + + - Outbox listeners compare the `actor` of a posted activity with + a portable outbox owner by their canonical portable IDs, so that + the actor can be referred to with `@gateway` location hints, another + URI scheme, or as a compatible identifier on another gateway. + [[#1159], [#1189]] + + - Changed Fedify to treat documents whose IDs are [FEP-ef61] compatible + identifiers, such as + `https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor`, as + the portable objects they stand for, as FEP-ef61 requires, instead of + trusting them by the web origin that served them. Previously, any server + could act as + `https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor` + without a proof by Alice's DID. Some implementations, e.g., tootik, + identify their portable actors this way. [[#288], [#1093], [#1105]] + + - Inboxes now reject an activity of an actor whose ID is a compatible + identifier with `401 Unauthorized` unless it has a valid Object + Integrity Proof made by the actor's DID. Activities of actors that + publish compatible identifiers without signing them, or whose DIDs + use key types Fedify does not support, are no longer accepted. + + - `verifyObject()` never authenticates an attribution or actor whose ID + is a compatible identifier by a key at an ordinary URL. + + - HTTP Signature verification rejects a key at a compatible identifier + unless the actor document has a valid proof by the actor's DID and + vouches for the key. A key at an ordinary URL that claims a portable + actor as its owner or controller is no longer that actor's key, and + `getKeyOwner()` and `doesActorOwnKey()` no longer resolve portable + actors by web origin. + + - Changed property accessors to verify the [FEP-ef61] portable objects that + they dereference when a `Context` is passed as their options, e.g., + `await create.getObject(ctx)`, since the new `Context.verifyPortableObject` + property has the same name as their `verifyPortableObject` option. This + also applies to objects with ordinary HTTP(S) IDs: their references to + compatible identifiers are now verified as portable objects instead of + being trusted because of their origin. In inbox listeners, accessors do + so even without options. [[#288], [#1107], [#1120]] + + - Fixed `signObject()` so that a signed object keeps verifying after it is + assigned to a typed parent and the parent is serialized. `signObject()` + now captures the secured JSON document its proof covers, and nested + serialization embeds that document verbatim instead of rebuilding the + child under the parent's JSON-LD context. Producing a compound document, + such as a signed `Note` in a signed [FEP-ef61] portable `Create`, with + `signObject()` no longer requires assembling the JSON by hand. + [[#288], [#1041], [#1044], [#1051]] + + - The captured document is a snapshot: `clone()` does not carry it, and + mutating a signed object in place does not change it. Sign a clone + again when it has to be embedded as a secured child. + - Serialization falls back to the previous behavior for objects parsed + with `fromJsonLd()`, objects that already carried a proof, and + `toJsonLd()` calls whose `context` option could hide the internal + placeholder. + - Outgoing JSON-LD compatibility normalization now leaves a nested + self-contained secured document untouched while signing and sending, + so it cannot rewrite bytes that the child's own proof covers. + Inbound verification is unaffected. + - Fanout delivery now reuses the document the activity was already + serialized into instead of reparsing and reserializing it, which + previously invalidated an embedded signed child and the outer proof + that covered it. - Allowed object dispatchers to return `Tombstone` for deleted objects, as actor dispatchers already can. Fedify now serves such object URIs with @@ -453,98 +888,6 @@ To be released. that return tombstones to be served with `410 Gone` instead of `200 OK`. - - Changed `Context.routeActivity()` to compare an activity that has no - valid proof with its actors by their [FEP-fe34] origins once it has - dereferenced the activity, where the origin of an [FEP-ef61] portable ID, - i.e., an `ap:` or `ap+ef61:` URI or a compatible identifier, is its DID - rather than the gateway that serves it. Previously, it compared their web - origins, so a portable activity was routed even if its actors had other - DIDs. Now a portable activity is routed only if all of its actors have - the same DID as the activity itself, and an activity with an ordinary - HTTP(S) ID is not routed if any of its actors has a portable ID. - A portable activity dereferenced through a compatible identifier also - matches its `ap:` ID, and vice versa. [[#288], [#1146], [#1171]] - - - Changed `Context.sendActivity()` so that an activity containing - [FEP-ef61] portable objects gets at most one Object Integrity Proof. - Previously Fedify signed every outgoing activity once for each Ed25519 - key, which produced a proof set that Fedify's own inbox rejects in a - compound portable document. [[#288], [#1041], [#1045], [#1073]] - - - An activity that already carries a proof is sent as is. - - With several Ed25519 keys, a portable activity is signed only by the - key whose ID is a DID URL for the activity's DID. - - If no single key qualifies, or a non-portable activity that embeds - portable objects has several Ed25519 keys, `sendActivity()` rejects - with a `TypeError` before anything is delivered or queued. Pass - explicit sender keys with exactly one Ed25519 key, or sign the - activity with `signObject()` beforehand. - - An activity with portable objects in which any map carries a proof - set is rejected the same way. - - Activities without portable objects are signed as before. - - - Changed Fedify to treat documents whose IDs are [FEP-ef61] compatible - identifiers, such as - `https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor`, as the - portable objects they stand for, as FEP-ef61 requires. Some - implementations, e.g., tootik, identify their portable actors this way. - Previously, Fedify trusted such documents by the web origin that served - them, so any server could act as - `https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor` - without a proof by Alice's DID. [[#288], [#1093], [#1105]] - - - `verifyPortableObjectProof()` now verifies a document whose `@id` is - a compatible identifier against the DID in it, instead of reporting - it as `notPortableObject`. The proof is verified over the document - as is. - - - Inboxes now reject an activity of an actor whose ID is a compatible - identifier with `401 Unauthorized` unless it has a valid Object - Integrity Proof made by the actor's DID, as they already did for - actors with `ap:` IDs. Likewise, an activity whose own ID is an - `ap:` URI or a compatible identifier is rejected unless its proof is - made by the DID of that ID, and embedded maps with compatible - identifiers need their own proofs as embedded portable objects do. - The exception is a key embedded in the `publicKey` or - `assertionMethod` of a verified portable actor, such as a gateway - key, whose ID is the actor's compatible identifier plus a fragment; - the actor's proof covers it. - Activities of actors that publish compatible identifiers without - signing them, or whose DIDs use key types Fedify does not support, - are no longer accepted. - - - `verifyObject()` now accepts an Object Integrity Proof made by a DID - as authenticating an attribution or actor whose compatible identifier - has that DID, and never authenticates such an attribution by a key at - an ordinary URL. - - - HTTP Signature verification now resolves a gateway key whose actor - document has a compatible identifier as its ID the same way as for - actors with `ap:` IDs, and rejects the key if the document does not - have a valid proof by the actor's DID, instead of trusting the - gateway's web origin. A key at an ordinary URL that claims - a portable actor as its owner or controller is no longer that actor's - key, and `getKeyOwner()` and `doesActorOwnKey()` no longer resolve - portable actors by web origin. - - - `Context.sendActivity()` now applies the compound-proof key selection - to activities that embed objects with compatible identifiers, as it - does for embedded objects with `ap:` IDs, so that Fedify inboxes - accept what it sends. - - - Changed HTTP Signature verification to accept a gateway key of a portable - actor that the actor's signed document embeds only in its `publicKey`, - with the actor as its `owner`, when no `assertionMethod` entry has the - key ID. Some publishers, e.g., tootik, list their RSA keys only there. - A document that has more than one entry with the key ID in either - property is rejected. [[#288], [#1093], [#1105]] - - - Changed the gateway trust policy for unsecured portable collections in - `verifyPortableObject()` to accept collections and owners identified by - compatible identifiers. An owner with a compatible identifier is fetched - through the gateway that the identifier names first, unless the - `gateways` option is given. [[#288], [#1093], [#1105]] - - Changed HTTP Signature verification to verify at most the first three RFC 9421 signatures of a request, in the order of its `Signature-Input` header, and to ignore the rest. Each signature may make Fedify fetch the @@ -615,42 +958,6 @@ To be released. store compares encoded values. Existing half-open states can now recover after switching to a CAS-capable store. [[#1163], [#1167]] - - Fixed outbox listeners rejecting a posted activity with - `400 Bad Request` when its `actor` referred to an [FEP-ef61] portable - outbox owner in another form than the owner's ID, e.g., with `@gateway` - location hints, another URI scheme or DID encoding, or as a compatible - identifier on another gateway. Portable actor IDs are now compared by - their canonical forms, while other actor IDs still have to match exactly. - [[#288], [#1159], [#1189]] - - - Fixed portable actor and object URI helpers so a dispatcher identifier - containing a `.` or `..` path segment raises a `TypeError` instead of - silently producing another object's ID. Use identifiers without such - segments. [[#288], [#1154], [#1186]] - - - Fixed `signObject()` so that a signed object keeps verifying after it is - assigned to a typed parent and the parent is serialized. `signObject()` - now captures the secured JSON document its proof covers, and nested - serialization embeds that document verbatim instead of rebuilding the - child under the parent's JSON-LD context. Producing a [FEP-ef61] compound - document with `signObject()` no longer requires assembling the JSON by - hand. [[#288], [#1041], [#1044], [#1051]] - - - The captured document is a snapshot: `clone()` does not carry it, and - mutating a signed object in place does not change it. Sign a clone - again when it has to be embedded as a secured child. - - An object parsed with `fromJsonLd()`, an object that already carried a - proof, and a `toJsonLd()` `context` option that could hide the - internal placeholder all fall back to the previous behavior. - - Outgoing JSON-LD compatibility normalization now leaves a nested - self-contained secured document untouched while signing and sending, - so it cannot rewrite bytes that the child's own proof covers. - Inbound verification is unaffected. - - Fanout delivery now reuses the document the activity was already - serialized into instead of reparsing and reserializing it, which - previously invalidated an embedded signed child and the outer proof - that covered it. - - Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received proof option except `proofValue`, including `expires`, `domain`, `challenge`, `nonce`, and extension options. It now rejects expired or @@ -658,26 +965,6 @@ To be released. `challenge` values through `VerifyProofOptions` to prevent cross-domain or replay use. [[#968]] - - Added `verifyPortableObjectProof()` to enforce the [FEP-ef61] proof policy - for portable actors, activities, objects, and signed collections. Its - detailed result distinguishes documents outside the policy, unsecured - collections, missing or invalid [FEP-8b32] proofs, unsupported verification - methods, DID authority mismatches, and successful verification. - [[#832], [#968]] - - - Updated `verifyObject()` so [FEP-8b32] proofs signed by `did:key` - verification methods can authenticate portable objects whose owner is an - `ap:` or `ap+ef61:` URI with the same [FEP-fe34] cryptographic origin. - [[#829], [#926]] - - - Added local `did:key` verification method resolution for - [FEP-8b32] Object Integrity Proofs. `verifyProof()` can now verify - Ed25519 `eddsa-jcs-2022` proofs whose `verificationMethod` is a - `did:key:z...#z...` DID URL without fetching the verification method - as a remote JSON-LD document, which is required for [FEP-ef61] - portable objects. - [[#827], [#915]] - - Added support for the [ActivityPub Media Upload extension] so that servers can accept client-to-server media uploads: [[#754], [#927]] @@ -749,36 +1036,13 @@ To be released. `esnext.temporal` lib reference. [[#823], [#925]] - - Security: Inbox processing now independently verifies each portable actor, - activity, and object in a compound JSON document against its own map-local - Object Integrity Proof before dispatch. A valid outer proof no longer - authenticates an unsigned or invalid nested portable object. - Portable documents with proof sets or which exceed the inbox compound - traversal limits are rejected as unsupported. The top-level Linked - Data Signature of an activity is not part of any proof input, so an - activity that carries both proofs and a Linked Data Signature is - accepted. An unsigned key embedded in the `publicKey` or - `assertionMethod` of a verified portable actor needs no proof of its own - if its ID is the actor's compatible identifier or `ap:` or `ap+ef61:` URI - plus a non-empty fragment, as with the keys that [FEP-ae97] clients make - for their actors, which Mitra serves under `ap:` URIs. - [[#288], [#938], [#1041], [#1094], [#1102], [#1133], [#1138]] - - The map-local profile is Fedify's interim interpretation, since neither - FEP-8b32 nor Verifiable Credential Data Integrity defines the boundaries - of embedded proofs yet, and may change in Fedify 3.0. The new - [*Portable objects*] chapter of the manual describes - the [FEP-ef61] profile that Fedify supports, including this and the parts - of FEP-ef61 that Fedify does not implement. [[#288], [#1151], [#1198]] - [FEP-ef61]: https://w3id.org/fep/ef61 +[*Portable objects*]: https://fedify.dev/manual/portable +[FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 [key–value store guide]: https://fedify.dev/manual/kv -[FEP-8b32]: https://w3id.org/fep/8b32 [ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload [Standard Schema]: https://standardschema.dev/ -[FEP-ae97]: https://w3id.org/fep/ae97 -[*Portable objects*]: https://fedify.dev/manual/portable [#206]: https://github.com/fedify-dev/fedify/issues/206 [#288]: https://github.com/fedify-dev/fedify/issues/288 [#754]: https://github.com/fedify-dev/fedify/issues/754 @@ -873,7 +1137,9 @@ To be released. [#1186]: https://github.com/fedify-dev/fedify/pull/1186 [#1188]: https://github.com/fedify-dev/fedify/pull/1188 [#1189]: https://github.com/fedify-dev/fedify/pull/1189 +[#1191]: https://github.com/fedify-dev/fedify/issues/1191 [#1198]: https://github.com/fedify-dev/fedify/pull/1198 +[#1201]: https://github.com/fedify-dev/fedify/pull/1201 ### @fedify/adonisjs @@ -905,53 +1171,66 @@ To be released. - Added \[SvelteKit\] option to `fedify init` command. This option allows users to initialize a new Fedify project with SvelteKit integration. [[#892], [#971] by Jang Hanarae\] + - Added `fedify.com.es` as a tunneling service. The CLI pins the service's SSH host key and rejects a mismatched server before exposing a local port. [[#940]] + - Removed `localhost.run` as a tunneling service. The service is no longer available, and the CLI now rejects attempts to use it. [[#940]] + - Switched the CLI's Temporal runtime dependency from `@js-temporal/polyfill` to `temporal-polyfill`. [[#823], [#925]] - - Added support for [FEP-ef61] portable objects to `fedify lookup`. It now - looks up portable objects by their `ap:` and `ap+ef61:` IDs, compatible - identifiers, and the WebFinger handles of portable actors, and verifies - their Object Integrity Proofs. Previously, - it failed to look up portable IDs, and refused portable objects found - through compatible identifiers as cross-origin objects. The same applies - to the collections that `-t`/`--traverse` traverses and the objects that - `--recurse` follows. When no gateway returns an acceptable object, the - command tells why, e.g., that the object's proof is invalid. + + - Added support for [FEP-ef61] portable objects to the `fedify` command. [[#288], [#1156], [#1199]] - - Added the `--gateway` option to `fedify lookup`, `fedify inbox`, and - `fedify webfinger`, which specifies the FEP-ef61 gateways to look up - portable objects from, e.g., for portable IDs without `@gateway` location - hints. [[#288], [#1156], [#1199]] - - Changed `fedify inbox -f`/`--follow` to follow FEP-ef61 portable actors, - and changed the `-a`/`--accept-follow` option of `fedify inbox` and the - `-a`/`--accept-follow` and `-r`/`--reject-follow` options of `fedify relay` - to accept portable IDs and compatible identifiers, which match the actor - regardless of `@gateway` location hints and the gateway of a compatible - identifier. [[#288], [#1156], [#1199]] - - Changed `fedify webfinger` to accept FEP-ef61 portable actor IDs. As such - an ID does not tell which server to ask, the command looks up the actor and - then its WebFinger address, which consists of its `preferredUsername` and - the host of its first gateway, and reports whether the response links back - to the actor. [[#288], [#1156], [#1199]] + + - `fedify lookup` now looks up portable objects by their `ap:` and + `ap+ef61:` IDs, compatible identifiers, and the WebFinger handles of + portable actors, and verifies their Object Integrity Proofs. + Previously, it failed to look up portable IDs, and refused portable + objects found through compatible identifiers as cross-origin objects. + The same applies to the collections that `-t`/`--traverse` traverses + and the objects that `--recurse` follows. When no gateway returns + an acceptable object, the command tells why, e.g., that the object's + proof is invalid. + + - Added the `--gateway` option to `fedify lookup`, `fedify inbox`, and + `fedify webfinger`, which specifies the gateways to look up portable + objects from, e.g., for portable IDs without `@gateway` location + hints. + + - `fedify inbox -f`/`--follow` now follows portable actors, and + the `-a`/`--accept-follow` option of `fedify inbox` and + the `-a`/`--accept-follow` and `-r`/`--reject-follow` options of + `fedify relay` accept portable IDs and compatible identifiers, which + match the actor regardless of `@gateway` location hints and + the gateway of a compatible identifier. + + - `fedify webfinger` now accepts portable actor IDs. As such an ID + does not tell which server to ask, the command looks up the actor + and then its WebFinger address, which consists of its + `preferredUsername` and the host of its first gateway, and reports + whether the response links back to the actor. + - Fixed `fedify lookup --recurse` reporting a timeout or another network failure of the first object or of a linked object as a possibly private object, suggesting the `-a`/`--authorized-fetch` option. It now reports the actual cause, e.g., “Request timed out after 10 seconds,” like the other modes of `fedify lookup` do. [[#1156], [#1199]] + - Fixed the `-p`/`--allow-private-address` option of `fedify webfinger` being ignored. [[#1156], [#1199]] + - Changed `fedify lookup` to time out each request after 10 seconds when the `-T`/`--timeout` option is not given, since the document loaders it uses now have a default timeout. Previously, there was no timeout by default. The `-T`/`--timeout` option now also limits how long a request waits for a DNS lookup, though it cannot stop the lookup itself. [[#1131], [#1169]] + - Updated Optique to 1.3.2. This fixes several command-line parsing issues, so options with attached values such as `--timeout=30` are handled consistently, typo suggestions for mistyped options are more @@ -1012,7 +1291,6 @@ To be released. [GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/ [FEP-044f]: https://w3id.org/fep/044f -[FEP-7aa9]: https://w3id.org/fep/7aa9 [#811]: https://github.com/fedify-dev/fedify/issues/811 [#929]: https://github.com/fedify-dev/fedify/pull/929 @@ -1062,10 +1340,11 @@ To be released. counts wherever it is passed. [[#900], [#1050] by Jae-Hyuk-Jang\] - - Fixed actor URI mismatch rules reporting portable actor and collection - URIs, including gateway-compatible IDs built from them. Applications can - use the `getPortable*Uri()` helpers in actor dispatchers without disabling - these rules. [[#1157], [#1179]] + - Changed the actor URI mismatch rules to accept the [FEP-ef61] portable + IDs of actors and collections, and the compatible identifiers built from + them, so that applications can use the `getPortable*Uri()` methods of + `Context` in actor dispatchers without disabling these rules. + [[#288], [#1157], [#1179]] - Fixed `outbox-listener-delivery-required` and `outbox-listener-delivery-not-awaited` (`@fedify/lint`) losing track of @@ -1138,7 +1417,7 @@ To be released. `isFederationRequest()` now recognizes them by their paths regardless of their headers. To make Next.js run the middleware for them, add `{ source: "/.well-known/apgateway/:path*" }` to the `matcher` of your - *middleware.ts* or *proxy.ts* file. [[#288], [#1149], [#1170]] + *middleware.ts* or *proxy.ts* file. [[#288], [#1149], [#1170]] - Added `isHashlinkMediaRequest()` function. [[#288], [#1149], [#1170]] [#1149]: https://github.com/fedify-dev/fedify/issues/1149 @@ -1214,102 +1493,197 @@ To be released. ### @fedify/testing - - Added fixture document loaders and portable-object verification to mock - context lookups, explicit portable gateway request information for mock - dispatchers, and hashlink media responses from mock federations. Tests can - now exercise these FEP-ef61 paths without a live gateway. - [[#288], [#1161], [#1196]] + - Added support for [FEP-ef61] portable objects to the mock federation and + contexts, following the new APIs of `@fedify/fedify`, so that tests can + exercise portable objects without a live gateway. [[#288]] + + - Added the `getPortableActorUri()`, `getPortableObjectUri()`, + `getPortableInboxUri()`, `getPortableOutboxUri()`, + `getPortableFollowingUri()`, `getPortableFollowersUri()`, + `getPortableLikedUri()`, `getPortableFeaturedUri()`, + `getPortableFeaturedTagsUri()`, and `getPortableCollectionUri()` + methods to the mock contexts. They reject `did:key` DIDs that are + not encoded in base58-btc. + [[#835], [#839], [#841], [#1092], [#1111], [#1114], [#1142]] + + - Added the `portable` option to `parseUri()` of the mock contexts. + Without it, `parseUri()` no longer recognizes a portable ID or + compatible identifier whose path starts with `/users/`; with it, + the result has the DID in its `authority` property. It also returns + `null` for `null`. [[#1143], [#1145]] + + - The mock contexts that `createContext()` creates keep + the `verifyPortableObject` property given to them, and their + `lookupObject()` and `traverseCollection()` pass it on. + `createFederation()` accepts a fixture `documentLoader`, + a `contextLoader`, and `verifyPortableObject`, which mock context + lookups use for portable IDs. [[#1107], [#1120], [#1161], [#1196]] + + - Added the `isSignedByAudience()` method to the mock request contexts, + which checks the audience against the actor that + `getSignedKeyOwner()` returns. [[#1153], [#1183]] + + - `federation.createContext()` accepts an explicit `portableRequest` + for a request context, which dispatchers can inspect. + [[#1161], [#1196]] + + - Added the `mapPortableActorId()` method to the setters that + `MockFederation.setActorDispatcher()` returns, the + `mapPortableOwner()` method to the mock custom collection setters, + and the `setHashlinkMediaDispatcher()` method to the mock federation, + which serves hashlink media responses. + [[#838], [#840], [#1080], [#1099], [#1111], [#1142], [#1161], [#1196]] + + - Added support for registering `onRequestFinished()` on mock federations so + applications can reuse their inbox configuration in tests. + [[#1191], [#1201]] + - Added `testKvStore()`, a conformance test suite for `KvStore` implementations, complementing `testMessageQueue()`. [[#1018], [#1020] by ChanHaeng Lee\] - - Added the `getPortableActorUri()` method to the mock contexts that - `createFederation()`, `createContext()`, `createRequestContext()`, - `createInboxContext()`, and `createOutboxContext()` create, following - the new `Context.getPortableActorUri()` method of `@fedify/fedify`. - The mock contexts' portable ID methods now also reject `did:key` DIDs - that are not encoded in base58-btc. [[#288], [#841], [#1114]] - - Added the `getPortableInboxUri()` method to the mock contexts that - `createFederation()`, `createContext()`, `createRequestContext()`, - `createInboxContext()`, and `createOutboxContext()` create, following - the new `Context.getPortableInboxUri()` method of `@fedify/fedify`. - [[#288], [#839], [#1092]] - - Added the `getPortableOutboxUri()`, `getPortableFollowingUri()`, - `getPortableFollowersUri()`, `getPortableLikedUri()`, - `getPortableFeaturedUri()`, `getPortableFeaturedTagsUri()`, and - `getPortableCollectionUri()` methods to the mock contexts, and - the `mapPortableOwner()` method to the mock custom collection setters, - following the new APIs of `@fedify/fedify`. [[#288], [#1111], [#1142]] - - Added the `isSignedByAudience()` method to the mock contexts that - `createFederation()` and `createRequestContext()` create, following - the new `RequestContext.isSignedByAudience()` method of `@fedify/fedify`. - It checks the audience against the actor that `getSignedKeyOwner()` - returns. [[#288], [#1153], [#1183]] - - Added the `mapPortableActorId()` method to the setters that - `MockFederation.setActorDispatcher()` returns, which `ActorCallbackSetters` - gained for [FEP-ef61] portable actors. [[#288], [#840], [#1099]] - - Added the `setHashlinkMediaDispatcher()` method to the `Federation` - object returned by `createFederation()`, following the new - `Federatable.setHashlinkMediaDispatcher()` method of `@fedify/fedify`. - [[#288], [#838], [#1080]] + - Changed `getObject()` of the mock context that `createFederation()` creates to follow `RequestContext.getObject()` of `@fedify/fedify`: it now returns `null` for a `Tombstone` that the object dispatcher returns, unless the tombstone is an instance of the requested class or `{ tombstone: "passthrough" }` is given. [[#1112], [#1117]] - - Changed `parseUri()` of the mock contexts to accept the `portable` option, - following `Context.parseUri()` of `@fedify/fedify`. It no longer - recognizes an FEP-ef61 portable ID or compatible identifier whose path - starts with `/users/` unless the option is enabled, in which case the - result has the DID in its `authority` property. It also returns `null` - for `null`. [[#288], [#1143], [#1145]] + - Fixed the CommonJS testing utilities build so it no longer requires `@js-temporal/polyfill` at runtime. The build now bundles `temporal-polyfill`, while type declarations rely on the standard `esnext.temporal` lib reference. [[#823], [#925]] - - The mock contexts that `createContext()` creates now keep the - `verifyPortableObject` property given to them, following the new - `Context.verifyPortableObject` property of `@fedify/fedify`, and their - default `lookupObject()` and `traverseCollection()` methods pass it and - a `verifyPortableObject` option given to them on. - [[#288], [#1107], [#1120]] [#1161]: https://github.com/fedify-dev/fedify/issues/1161 [#1196]: https://github.com/fedify-dev/fedify/pull/1196 ### @fedify/vocab - - Added [FEP-ef61] gateway dereferencing to property accessors such as - `Create.getObject()`. Accessors now fetch `ap:` and `ap+ef61:` - references through the gateways given by the new `gateways` option, or - through the `@gateway` location hints in the reference when the option is - omitted, trying each gateway in order until one serves a valid object. - A fetched object is returned only if its `@id` canonically matches the - reference and the new `verifyPortableObject` option, typically - `verifyPortableObjectProof()` from `@fedify/fedify`, accepts it. Portable - references can no longer be dereferenced without `verifyPortableObject`, - and the `crossOrigin: "trust"` option does not skip these checks. - HTTP(S) references are fetched as before. [[#288], [#834], [#1077]] - - - Added [FEP-ef61] vocabulary terms for portable ActivityPub objects. - Actor classes now expose ordered `gateways` lists, and `Link` plus - document/media classes expose `digestMultibase` for external resource - integrity metadata. - [[#830], [#928]] - - - Updated [FEP-fe34] cross-origin checks to understand cryptographic origins - for [FEP-ef61] portable ActivityPub IDs and DID URLs. Generated property - accessors and `lookupObject()` now treat `ap:`/`ap+ef61:` IDs and matching - `did:key` verification method IDs as same-origin when their DID components - match. - [[#829], [#926]] - - - Added support for [FEP-ef61] portable ActivityPub IRIs in generated - vocabulary codecs. `ap:` and `ap+ef61:` values with decoded or - percent-encoded DID authorities now parse as `URL` objects, and JSON-LD - serialization emits canonical `ap+ef61:` values with decoded DID - authorities. - [[#826], [#850]] + - Added support for [FEP-ef61] portable objects, whose IDs are `ap:` or + `ap+ef61:` URIs with a DID instead of a host, e.g., + `ap://did:key:z6Mk.../actor`, and which are retrieved through the + servers listed in their actors' `gateways`. See the + [*Portable objects*] chapter of the manual for the + FEP-ef61 profile that Fedify supports. [[#288]] + + - Generated vocabulary classes now accept portable IDs with decoded or + percent-encoded DID authorities wherever an IRI is expected, and + serialize them as canonical `ap+ef61:` IRIs with decoded DID + authorities. Inspecting vocabulary objects, e.g., with + `console.log()`, also shows them in this form. + [[#826], [#850], [#1156], [#1199]] + + - Added the `gateways` property to actor classes, and the + `digestMultibase` property to `Link` and document and media classes. + Gateways are serialized as origins without a trailing slash. + A portable actor that uses the `gateways` term without mapping it in + its JSON-LD context, as tootik does, is also read. + [[#830], [#928], [#1097], [#1202]] + + - Added the optional `Recipient.gateways` property, through which Fedify + delivers activities to portable inboxes. Applications that build + `Recipient` objects by hand, e.g., in followers collection + dispatchers, need to set it for portable actors. [[#1147], [#1180]] + + - Property accessors such as `Create.getObject()` now fetch portable + references through gateways: those in the new `gateways` option, or + else those in the `@gateway` location hints of the reference. A + reference without hints that has the same DID as a portable actor it + was reached from, e.g., the actor's `outbox`, is fetched through that + actor's `gateways`. A fetched object is returned only if its `@id` + identifies the referenced portable object and the verifier given as the + new `verifyPortableObject` option accepts it, typically + `verifyPortableObject()` from `@fedify/fedify`, or a `Context` passed + as the options. Portable references cannot be dereferenced without the + option, and `crossOrigin: "trust"` does not skip these checks. + [[#834], [#1077], [#1093], [#1105]] + + - Property accessors tell the `verifyPortableObject` function where + a portable object was retrieved from and which objects led to it, so + that it can accept a portable collection without a proof if a gateway + that its owner lists served it. Objects embedded in such a collection + are fetched and verified one by one. Added the `crossOrigin`, + `gateways`, and `verifyPortableObject` options to + `TraverseCollectionOptions` as well. [[#836], [#1084]] + + - Added the `verifyPortableObject` option to the constructors, + the `fromJsonLd()` methods, and the `clone()` methods of vocabulary + classes, which sets the verifier that the object's property accessors + use by default. Objects embedded in the parsed document, and objects + that accessors and `traverseCollection()` fetch, get the verifier of + the call or of their parent by default, so that, e.g., + `(await create.getObject(ctx))?.getAttribution()` verifies portable + objects without passing `ctx` again. Having a default verifier does + not mean that an object was verified. Added + the `inheritPortableObjectVerifier` option to property accessors and + `TraverseCollectionOptions` to limit a verifier to a single call. + [[#1107], [#1120], [#1129], [#1137]] + + - Added the `verifyPortableObject` and `gateways` options to + `LookupObjectOptions`. With the former, `lookupObject()` looks up + portable IDs through their `@gateway` location hints or the gateways + given by the latter, compatible identifiers through the gateways they + name, and the handles of portable actors through their WebFinger + responses, trying at most five gateways per lookup. The gateways + that it infers from compatible identifiers, WebFinger responses, and + location hints are passed to the `verifyPortableObject` function as + `gatewayHints`, while `gateways` has the ones given explicitly. + [[#837], [#1082], [#1090], [#1091], [#1158], [#1194]] + + - `getActorHandle()` now supports portable actors. It takes the domain + of a portable actor's handle from the first gateway in its `gateways`, + and returns the handle only if its WebFinger response links back to + the actor. [[#837], [#1082]] + + - Exported the portable object verifier types and other types used in + vocabulary API signatures from `@fedify/vocab`, so that custom + verifiers can be typed without importing `@fedify/vocab-runtime`. + [[#1160], [#1184]] + + - Changed property accessors and `lookupObject()` so that they no longer + trust [FEP-ef61] compatible identifiers, i.e., HTTP(S) URLs under + a gateway's */.well-known/apgateway/* path such as + `https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor`, because + of the origin that serves them. Anyone can serve a compatible identifier + for any DID, so previously anyone could serve an unsigned object that + claimed to be someone else's portable object. + [[#288], [#837], [#1082], [#1090], [#1091], [#1107], [#1120]] + + - With the `verifyPortableObject` option, a compatible identifier is + dereferenced as the portable object it stands for: through the + gateway that it names and then the gateways in the `gateways` option, + only if the option accepts the object. A document fetched from + an ordinary HTTP(S) URL is verified the same way if its final URL is + a compatible identifier or its `@id` is a portable ID. Malformed + compatible identifiers are rejected without a request. + + - Without the option, accessors keep fetching compatible identifiers as + ordinary HTTP(S) URLs, but no longer cache the results in the parent + objects, so that a later call with the option verifies them. + However, accessors of an object whose ID is a portable ID or + a compatible identifier now throw a `TypeError` (or return `null` with + `suppressError: true`) for such references without the option, as + for portable IDs. + + - Accessors no longer trust an embedded object whose `@id` is + a compatible identifier, or a portable ID with a DID other than its + parent's, even with `crossOrigin: "trust"`; they dereference and + verify it on its own. Likewise, `crossOrigin: "trust"` no longer + makes `lookupObject()` return an object with a portable `@id` from + a document URL of another origin. + + - Updated [FEP-fe34] cross-origin checks to understand the cryptographic + origins of [FEP-ef61] portable IDs and DID URLs. Property accessors and + `lookupObject()` now treat `ap:` and `ap+ef61:` IDs and `did:key` + verification method IDs as the same origin when their DIDs match. + [[#288], [#829], [#926]] + + - Changed nested serialization so that an object carrying the signed + JSON-LD representation that `signObject()` retains is embedded with that + exact representation, including its own `@context`, rather than being + rebuilt under the parent's context, so that its proof keeps verifying. + `clone()` never carries the retained representation, because a clone may + differ from the document the proof covers. [[#288], [#1044], [#1051]] - Added vocabulary support for [FEP-7aa9], including `FeaturedCollection`, `FeaturedItem`, `FeatureRequest`, and @@ -1327,47 +1701,6 @@ To be released. `esnext.temporal` lib reference. [[#823], [#925]] - - Added a `gateways` option to `lookupObject()` and - `Context.lookupObject()`, so applications can look up a bare - [FEP-ef61] portable object ID without constructing location hints or - providing a custom document loader. The listed gateways are tried in - order in place of any `@gateway` hints. [[#288], [#1158], [#1194]] - - - Added the optional `Recipient.gateways` property, the [FEP-ef61] gateways - of a portable actor, through which Fedify delivers activities to its - `ap:` or `ap+ef61:` inbox. Every actor class already has it, so - applications that build `Recipient` objects by hand, e.g., in followers - collection dispatchers, need to set it only for portable actors. - [[#288], [#1147], [#1180]] - - - Added the `verifyPortableObject` option to `LookupObjectOptions`. With - it, `lookupObject()` looks up [FEP-ef61] portable objects: `ap:` and - `ap+ef61:` URIs through their `@gateway` location hints, compatible - identifiers through the gateways they name, and portable actors' - handles through the `self` links of their WebFinger responses, which - can be either of them. A fetched object is returned only if its `@id` - identifies the requested portable object and the option accepts it; - otherwise, the next candidate is tried, at most five gateways per - lookup. `crossOrigin: "throw"` makes a rejected object throw an error, - and `crossOrigin: "trust"` does not skip the checks. - [[#288], [#837], [#1082]] - - - `crossOrigin: "trust"` no longer makes `lookupObject()` return an object - whose `@id` is an `ap:` or `ap+ef61:` URI from a document URL of another - origin, such as a compatible identifier fetched as an ordinary HTTP(S) - URL, since a portable object is authenticated by its proofs, not by the - server that serves it. Look such objects up with the - `verifyPortableObject` option instead. [[#288], [#837], [#1082]] - - - `getActorHandle()` now supports [FEP-ef61] portable actors. It takes the - domain of a portable actor's handle from the first gateway in its - `gateways` instead of its ID, and returns the handle only if its - WebFinger response links back to the actor, either by its portable ID - or by a compatible identifier, since the gateways are claimed by the - actor itself. It throws a `TypeError` otherwise, including when the - WebFinger lookup fails, and for a portable actor without `gateways` or - `preferredUsername` or a portable actor URI. [[#288], [#837], [#1082]] - - Added vocabulary support for the [FEP-22cd] draft, associating each translated version with its translators, source object, and optional source review timestamp. [[#1037], [#1038]] @@ -1382,117 +1715,6 @@ To be released. `Note`, and other object types, for per-language translation metadata without creating separate posts. - - Changed nested serialization so that an object carrying a signed JSON-LD - representation retained by `signObject()` is embedded with that exact - representation, including its own `@context`, rather than being rebuilt - under the parent's context. `clone()` never carries the retained - representation, because a clone may differ from the document the proof - covers. [[#288], [#1044], [#1051]] - - - Changed the inspection of vocabulary objects, e.g., with `console.log()`, - `util.inspect()`, or `Deno.inspect()`, to show [FEP-ef61] portable IDs - in their canonical form, e.g., `ap+ef61://did:key:z6Mk.../actor`, instead - of the percent-encoded form of the `URL` objects that represent them, - e.g., `ap+ef61://did%3Akey%3Az6Mk.../actor`. [[#288], [#1156], [#1199]] - - - Exported portable object verifier types from `@fedify/vocab`, so callers - can type custom verifiers without importing `@fedify/vocab-runtime`. - Also exported other types used in vocabulary API signatures. - [[#288], [#1160], [#1184]] - - - Property accessors and `lookupObject()` with the `verifyPortableObject` - option now accept a portable object whose `@id` is an [FEP-ef61] - compatible identifier of the requested portable object, e.g., one on the - gateway that served it, and pass it to `verifyPortableObject`, keeping its - own `@id`. Previously, such documents, which some implementations such - as tootik publish, were rejected as objects with a mismatching ID. - [[#288], [#1093], [#1105]] - - - Property accessors now tell the `verifyPortableObject` function where - a portable object was retrieved from and which objects led to it, so - that it can apply the [FEP-ef61] trust policy for portable collections - served without proofs. Accessors of such an unsecured collection do not - trust the objects embedded in it, even with `crossOrigin: "trust"`; - they fetch and verify each of them on its own, and drop the ones without - an `@id`. [[#288], [#836], [#1084], [#1090], [#1091]] - - - Added `crossOrigin`, `gateways`, and `verifyPortableObject` options to - `TraverseCollectionOptions`, so that `traverseCollection()` can traverse - [FEP-ef61] portable collections through gateways. - [[#288], [#836], [#1084], [#1090], [#1091]] - - - Property accessors of objects whose IDs are [FEP-ef61] compatible - identifiers, e.g., - `https://gw.example/.well-known/apgateway/did:key:z6Mk.../activities/1`, - now dereference their references as portable objects, as those of objects - with `ap:` IDs do. Previously, such an accessor fetched a compatible - identifier as an ordinary HTTP(S) URL and trusted the result because of - its origin, so anyone could serve an unsigned object at the reference. - Without the `verifyPortableObject` option, such an accessor now throws - a `TypeError` (or returns `null` with `suppressError: true`) for - a portable reference or a compatible identifier; pass a `Context` from - `@fedify/fedify`, which carries a verifier, or use a default verifier - (see below). An object whose ID is a malformed compatible identifier is - handled the same way. Having such an ID does not make an object - verified. [[#288], [#1107], [#1120]] - - - Added the `verifyPortableObject` option to the constructors, the - `fromJsonLd()` methods, and the `clone()` methods of vocabulary - classes. The object uses it by default when its property accessors are - called without the option. Objects embedded in the JSON-LD document it - was parsed from inherit it. `lookupObject()` passes its - `verifyPortableObject` option to the object it returns. - [[#288], [#1107], [#1120]] - - - Objects that property accessors and `traverseCollection()` fetch now use - the `verifyPortableObject` option of that call, or else the default of - the object they were obtained from, as their default verifier. So - `(await create.getObject(ctx))?.getAttribution()` verifies portable - objects without passing `ctx` again. Objects already cached in their - parent keep the default they got when they were fetched. Having - a default verifier does not mean that an object was verified. - [[#288], [#1129], [#1137]] - - - Added the `inheritPortableObjectVerifier` option to property accessors - and `TraverseCollectionOptions`. Set it to `false` to keep - a `verifyPortableObject` option to that call only, e.g., for a verifier - that accepts everything; fetched objects then use the default of the - object they were obtained from, if any. [[#288], [#1129], [#1137]] - - - Property accessors such as `Create.getObject()` now dereference - [FEP-ef61] compatible identifiers, i.e., HTTP(S) URLs under a gateway's - */.well-known/apgateway/* path, as the portable objects they stand for - when the `verifyPortableObject` option is given or the parent object is - portable. Previously, they fetched such a reference as an ordinary - HTTP(S) URL and trusted the result because of its origin, so anyone could - serve an unsigned object that claimed to be someone else's portable - object. Now the accessor asks the gateway that the identifier names - first, and then the gateways in the `gateways` option, and returns the - object only if its `@id` identifies the same portable object and - `verifyPortableObject` accepts it, including the gateway trust policy for - unsecured collections. Malformed compatible identifiers are rejected - without a request. [[#288], [#1090], [#1091]] - - - Property accessors and `lookupObject()` with the `verifyPortableObject` - option now verify a document fetched from an ordinary HTTP(S) URL as - a portable object if its final URL is a compatible identifier or its - `@id` is an `ap:`/`ap+ef61:` URI, instead of trusting it because of its - origin. Likewise, accessors no longer trust an embedded object whose - `@id` is a compatible identifier, or a portable ID with a DID other than - its parent's, even with `crossOrigin: "trust"`; they dereference and - verify it on its own. [[#288], [#1090], [#1091]] - - - Without the `verifyPortableObject` option, property accessors keep - fetching compatible identifiers from ordinary objects as HTTP(S) URLs, - but no longer cache the results in the parent objects, so that a later - call with the option verifies them. [[#288], [#1090], [#1091]] - - - The gateways that `lookupObject()` infers from compatible identifiers, - WebFinger responses, and location hints are now passed to the - `verifyPortableObject` function as `gatewayHints` instead of `gateways`, - which are reserved for gateways that the caller gives explicitly. - [[#288], [#1090], [#1091]] - [FEP-22cd]: https://w3id.org/fep/22cd [#810]: https://github.com/fedify-dev/fedify/issues/810 [#826]: https://github.com/fedify-dev/fedify/issues/826 @@ -1506,57 +1728,114 @@ To be released. [#1077]: https://github.com/fedify-dev/fedify/pull/1077 [#1090]: https://github.com/fedify-dev/fedify/issues/1090 [#1091]: https://github.com/fedify-dev/fedify/pull/1091 +[#1097]: https://github.com/fedify-dev/fedify/issues/1097 [#1129]: https://github.com/fedify-dev/fedify/issues/1129 [#1137]: https://github.com/fedify-dev/fedify/pull/1137 [#1158]: https://github.com/fedify-dev/fedify/issues/1158 [#1160]: https://github.com/fedify-dev/fedify/issues/1160 [#1184]: https://github.com/fedify-dev/fedify/pull/1184 [#1194]: https://github.com/fedify-dev/fedify/pull/1194 +[#1202]: https://github.com/fedify-dev/fedify/pull/1202 ### @fedify/vocab-runtime - - Added SHA-256 `digestMultibase` and simple `hl:` hashlink helpers for - computing, parsing, creating, and verifying portable media resource - digests as required by [FEP-ef61]. - [[#831], [#935]] - - - Added the [FEP-ef61] JSON-LD context to the preloaded context registry so - portable actor and media documents can compact and expand `gateways` and - `digestMultibase` without fetching the context remotely. - [[#830], [#928]] - - - Added `getFe34Origin()` and `haveSameFe34Origin()` for comparing ordinary - web origins and [FEP-ef61] cryptographic origins with one shared - [FEP-fe34] helper. HTTP(S) URLs keep web-origin semantics, while - `ap:`/`ap+ef61:` URIs and DID URLs use their DID component as the origin. - [[#829], [#926]] - - - Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for - comparing [FEP-ef61] portable ActivityPub URI strings. The helpers accept - `ap:` and `ap+ef61:` values with decoded or percent-encoded DID - authorities, normalize them to `ap+ef61:`, and ignore query hints such as - `gateways` during comparison. - [[#828], [#924]] - - Canonicalizing to `ap+ef61:` rather than `ap:`, which FEP-ef61 currently - recommends, is a deliberate choice of Fedify's that may change in - Fedify 3.0. Compare portable IDs with `arePortableUrisEqual()` rather - than as strings. See the [*Portable objects*] chapter - of the manual for the FEP-ef61 profile that Fedify supports. - [[#288], [#1151], [#1198]] + - Added to preloaded JSON-LD contexts. + [[#1037], [#1038]] + + - Added support for [FEP-ef61] portable objects, whose IDs are `ap:` or + `ap+ef61:` URIs with a DID instead of a host, e.g., + `ap://did:key:z6Mk.../actor`. See the + [*Portable objects*] chapter of the manual for the + FEP-ef61 profile that Fedify supports. [[#288]] + + - Added `parseIri()`, `formatIri()`, `parseJsonLdId()`, and + `haveSameIriOrigin()`, which parse, format, and compare IRIs, + including portable IDs with decoded or percent-encoded DID + authorities. The `URL` objects that represent portable IDs keep + their DIDs percent-encoded, and `formatIri()` formats them in their + canonical form, e.g., `ap+ef61://did:key:z6Mk.../actor`. Portable + IDs and compatible identifiers whose paths have `.` or `..` + segments, which the `URL` class would remove, are rejected with + a `TypeError`. [[#826], [#850], [#1154], [#1186]] + + - Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for + comparing portable IDs. They accept both schemes with decoded or + percent-encoded DID authorities, normalize them to `ap+ef61:`, and + ignore the query, including `@gateway` location hints. + Fedify deliberately canonicalizes to `ap+ef61:` rather than `ap:`, + which FEP-ef61 currently recommends, and this may change in + Fedify 3.0, so compare portable IDs with + `arePortableUrisEqual()` rather than as strings. + [[#828], [#924], [#1151], [#1198]] + + - Added `getFe34Origin()` and `haveSameFe34Origin()`, which compare + [FEP-fe34] origins: HTTP(S) URLs have their web origins, while + portable IDs and DID URLs have their DIDs as their cryptographic + origins. [[#829], [#926]] + + - Added `exportDidKey()`, `importDidKey()`, and + `parseDidKeyVerificationMethod()` for Ed25519 `did:key` DIDs and + their verification method DID URLs. `exportDidKey()` always + encodes DIDs in base58-btc, as FEP-ef61 requires. [[#827], [#915]] + + - Added `toCompatibleEf61Id()` and `fromCompatibleEf61Id()` for + converting between portable IDs and compatible identifiers, i.e., + HTTP(S) URLs under a gateway's */.well-known/apgateway/* path such as + `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, + which software without portable ID support can use. + `fromCompatibleEf61Id()` returns `null` for URLs that are not + compatible identifiers, and throws a `TypeError` for malformed ones, + including those with location hints. Converting a compatible + identifier does not authenticate it. [[#833], [#1074]] + + - Added `isGatewayUrl()` and `parseGatewayUrl()`, which check that + a gateway is an HTTP(S) origin without credentials, a path, a query, + or a fragment, as FEP-ef61 requires. + [[#830], [#928], [#1176], [#1190]] + + - Added `withGatewayHints()`, `withoutGatewayHints()`, and + `getGatewayHints()` to add, remove, and read the `@gateway` location + hints of portable IDs, which tell consumers where to retrieve + a referenced portable actor. [[#1159], [#1189]] + + - Added SHA-256 `digestMultibase` and `hl:` hashlink helpers: + `computeDigestMultibase()`, `parseDigestMultibase()`, + `verifyDigestMultibase()`, `createHashlink()`, `parseHashlink()`, + and `verifyHashlink()`. [[#831], [#935]] + + - Added `fetchPortableMedia()`, which retrieves the media of a portable + object through its owner's gateways, or an HTTP(S) resource directly, + and returns it only after verifying its `digestMultibase`. + It limits the response size and rejects private network addresses by + default. [[#1152], [#1182]] + + - Added the FEP-ef61 JSON-LD context to the preloaded contexts, so that + `gateways` and `digestMultibase` can be compacted and expanded + without fetching the context. [[#830], [#928]] + + - Added the `PortableObjectVerifier`, `PortableObjectVerifierOptions`, + `PortableObjectVerification`, and `PortableObjectReferrer` types, + which describe the `verifyPortableObject` option of property + accessors. A verifier receives a fetched document along with its + final URL, the gateways used, and the chain of objects that referred + to it, and can accept a collection without a proof as `unsecured`. + [[#834], [#836], [#1077], [#1084]] + + - Added the `verifyPortableObject` property to + `PropertyPreprocessorContext`, the default verifier that objects + returned by a property preprocessor should use. [[#1107], [#1120]] + + - Changed the `Accept` header that document loaders send when fetching + ActivityPub objects to + `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, + as ActivityPub and [FEP-ef61] gateways require. Previously, the JSON-LD + media type lacked the ActivityStreams profile. [[#288], [#834], [#1077]] - Added the [FEP-7aa9] JSON-LD context to the preloaded context registry so FEP-7aa9 documents can be compacted and expanded without fetching the context remotely. [[#810], [#914]] - - Added helpers for Ed25519 `did:key` DIDs and verification method DID - URLs: `exportDidKey()` exports public keys to base58-btc `did:key` DIDs, - `importDidKey()` imports supported DIDs back to `CryptoKey`, and - `parseDidKeyVerificationMethod()` validates `did:key:z...#z...` - verification methods. - [[#827], [#915]] - - Changed `getDocumentLoader()` to reject HTML and XHTML responses that do not advertise an ActivityPub alternate document with a `FetchError` instead of attempting to parse the HTML as JSON. This makes remote HTML @@ -1564,51 +1843,6 @@ To be released. content type, rather than generic JSON parser crashes. [[#912], [#913]] - - Added `fetchPortableMedia()` to retrieve [FEP-ef61] hashlink media through - an actor's gateways or HTTP(S) media directly, and return the resource only - after verifying its `digestMultibase`. It rejects missing or mismatched - digests, limits response size, and checks private network addresses by - default. [[#288], [#1152], [#1182]] - - - Added to preloaded JSON-LD contexts. - [[#1037], [#1038]] - - - Added the `PortableObjectVerifier`, `PortableObjectVerifierOptions`, - `PortableObjectVerification`, and `PortableObjectReferrer` types, which - describe the `verifyPortableObject` option of property accessors for - [FEP-ef61] portable references. A verifier receives the fetched - document along with its final URL, the gateways used, and the chain of - objects that referred to it, and can accept a document without an - integrity proof as `unsecured`. `verifyPortableObject()` and - `verifyPortableObjectProof()` from `@fedify/fedify` satisfy - `PortableObjectVerifier`. [[#288], [#834], [#836], [#1077], [#1084]] - - - Added the `verifyPortableObject` property to - `PropertyPreprocessorContext`, the default [FEP-ef61] portable object - verifier that objects returned by a property preprocessor should use for - their property accessors. [[#288], [#1107], [#1120]] - - - Added `toCompatibleEf61Id()` and `fromCompatibleEf61Id()` for converting - between [FEP-ef61] portable IDs and compatible identifiers, which are - HTTP(S) URLs under a gateway's fixed `/.well-known/apgateway/` path that - software without portable IRI support can use. `toCompatibleEf61Id()` - removes location hints (`@gateway` query parameters, and the legacy - `gateways` parameter), and `fromCompatibleEf61Id()` returns - `null` for URLs that are not compatible identifiers and throws a - `TypeError` for malformed ones, including those with location hints. - Converting a compatible identifier does not authenticate it; the object's - proof still has to be verified against its DID. [[#288], [#833], [#1074]] - - - Added `withGatewayHints()`, `withoutGatewayHints()`, and - `getGatewayHints()` to add, remove, and read the [FEP-ef61] `@gateway` - location hints of portable IDs, which tell consumers where to retrieve - a referenced portable actor. Use `withGatewayHints()` when referring to - a portable actor, e.g., in `attributedTo` or `to`, instead of editing - the query by hand; it writes each gateway's URI-encoded origin as - FEP-ef61 requires, replaces existing hints, and keeps the other query - parameters. Do not add hints to an object's own `id`. - [[#288], [#1159], [#1189]] - - Changed `getDocumentLoader()` to time out each call after 10 seconds by default. The time limit covers the whole call, including every redirect and alternate document it follows and reading the response body. @@ -1626,42 +1860,289 @@ To be released. byte, or only the status and headers if the body is larger than that. [[#1131], [#1169]] - - Changed the `Accept` header that document loaders send when fetching - ActivityPub objects to - `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, - as ActivityPub and [FEP-ef61] gateways require. Previously, the JSON-LD - media type lacked the ActivityStreams profile. [[#288], [#834], [#1077]] +[#828]: https://github.com/fedify-dev/fedify/issues/828 +[#831]: https://github.com/fedify-dev/fedify/issues/831 +[#833]: https://github.com/fedify-dev/fedify/issues/833 +[#912]: https://github.com/fedify-dev/fedify/issues/912 +[#913]: https://github.com/fedify-dev/fedify/pull/913 +[#924]: https://github.com/fedify-dev/fedify/pull/924 +[#935]: https://github.com/fedify-dev/fedify/pull/935 +[#1074]: https://github.com/fedify-dev/fedify/pull/1074 +[#1152]: https://github.com/fedify-dev/fedify/issues/1152 +[#1176]: https://github.com/fedify-dev/fedify/issues/1176 +[#1182]: https://github.com/fedify-dev/fedify/pull/1182 +[#1190]: https://github.com/fedify-dev/fedify/pull/1190 + +### @fedify/vocab-tools + + - Added the `extraContext` property schema option to include a JSON-LD + context only when its terms are used, preserving existing output for + objects without the extension. [[#1037], [#1038]] + - Added the `trustEmbeddedObjects` type schema option so embedded metadata + identifiers need not establish trust in linked actors. [[#1037], [#1038]] + - Changed suppressed vocabulary fetch and parsing failures to log at the + warning level so that intentionally handled failures are not reported as + application errors. [[#933], [#1035] by Jae Hui Hong\] + +[#933]: https://github.com/fedify-dev/fedify/issues/933 +[#1035]: https://github.com/fedify-dev/fedify/pull/1035 + + +Version 2.3.14 +-------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` accepting URLs with a username + or password. Such URLs now throw `UrlError` before fetching on all + runtimes, including redirect and alternate document targets, regardless + of `allowPrivateAddress`. + [[#1294], [#1295]] + - Fixed key ownership checks accepting another actor's document when + resolving an actor ID. When an actor ID redirected to, or directly + served, the document of a different actor, keys listed by that other + actor were attributed to the requested one, so an attacker whose actor + document was reached through a victim's actor ID could sign activities + that passed authentication as the victim's. This affected HTTP + Signatures, Linked Data Signatures, and Object Integrity Proofs. Fetched + actor documents are now accepted only when their `id` is exactly the + requested actor ID. \[[GHSA-8863-8468-7hq7]] + - As a consequence of the above fix, an actor whose ID redirects to + a document with a different `id`, even on the same origin and for the + same account, can no longer have its keys confirmed through that + redirect. Servers that normalize actor URLs should use the canonical + actor ID, which responds without redirecting, as the `owner` or + `controller` of their keys and as the `actor` of their activities. + [[GHSA-8863-8468-7hq7]] + - The built-in `KvStore`-backed key cache now uses a new generation, so + keys cached under the weaker check are fetched and verified again after + upgrading. Applications with a custom `KeyCache` implementation should + clear it once when upgrading. \[[GHSA-8863-8468-7hq7]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` accepting URLs with a username or password. + Such URLs now throw `UrlError` before fetching on all runtimes, including + redirect and alternate document targets, regardless of + `allowPrivateAddress`. + [[#1294], [#1295]] + + +Version 2.3.13 +-------------- + +Released on October 11, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. + [[#1292], [#1293]] + - Fixed inboxes accepting signed activities whose IDs had a different origin + from their actors. Such deliveries now receive `401 Unauthorized` before + they can supply trusted embedded objects or reserve the genuine activity's + idempotency entry. Queued and manually routed activities apply the same + origin check. \[[GHSA-4qhx-hhwf-8685]] + +### @fedify/cli + + - Fixed `fedify webfinger` ignoring `-p`/`--allow-private-address` and the + `webfinger.allowPrivateAddress` configuration setting, preventing lookups + against local development servers even with an explicit opt-in. Private + addresses remain blocked by default. + [[#1274], [#1276]] + +[#1274]: https://github.com/fedify-dev/fedify/issues/1274 +[#1276]: https://github.com/fedify-dev/fedify/pull/1276 + +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nuxt + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when no + application route or Nuxt page could serve a representation. Renderer + 404s are now converted back to 406 before Nitro's error handler runs, + so these responses do not reach Nitro's error handler or error hooks. + Deliberate 404s from matched server routes are preserved. + [[#1278], [#1279]] + +### @fedify/solidstart + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when an + application returned a `Response` with status 404, returned no response, + or threw a 404 error. Responses with other statuses are preserved. The + 406 is restored for buffered 404 responses before headers are sent; + responses whose headers have already been sent cannot be changed. Thrown + errors still reach Nitro's error hooks. + [[#1278], [#1279]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. + [[#1292], [#1293]] + + +Version 2.3.12 +-------------- + +Released on October 9, 2026. + +### @fedify/fedify + + - Fixed signature verification throwing an exception when a remote actor + supplied a malformed JSON-LD context or its context could not be fetched. + These requesters are now treated as unverifiable. + [[#1267], [#1270]] + +### @fedify/backfill + + - Fixed conversation backfill skipping posts in paginated context and replies + collections. Backfill now follows the first and subsequent pages while + respecting traversal limits, and retains posts already found if a page + cannot be loaded. [[#1248], [#1256] by Jiwon Kwon\] + - Fixed conversation backfill stopping when the context collection loader + failed. Failed context loads are now skipped so later configured strategies + can still find accessible posts. Cancellation and interval configuration + errors continue to propagate. [[#1249], [#1258] by Jiwon Kwon\] + +### @fedify/cfworkers + + - Fixed npm refusing to install `@fedify/cfworkers` alongside + `@cloudflare/workers-types` 5.x, which recent versions of Wrangler + require, unless `--legacy-peer-deps` or an override was used. The peer + dependency on `@cloudflare/workers-types` now accepts both 4.x and 5.x, + so you can drop such workarounds. + [[#1255], [#1260]] + +### @fedify/express + + - Fixed asynchronous `contextDataFactory` and `federation.fetch()` failures + being left as unhandled rejections in the Express integration. Errors + that occur before Fedify passes the request to the next middleware now + reach Express error-handling middleware, so applications can send their + usual error response. + [[#1244], [#1261]] + +### @fedify/postgres + + - Fixed `PostgresMessageQueue` remaining unusable after a transient + initialization failure. Later calls now retry initialization, so the same + queue instance can recover when the database becomes available again. + [[#1268], [#1271]] + + +Version 2.3.11 +-------------- + +Released on October 5, 2026. + +### @fedify/fedify - - Fixed portable and gateway-compatible ID strings with `.` or `..` path - segments being interpreted as different IDs after URL parsing. Fedify now - rejects these strings because the segments cannot be preserved by its - URL-based APIs. Raw portable ID strings can still be compared without - losing their paths. [[#288], [#1154], [#1186]] + - Fixed cached signing-key fetch failures losing their error types, URLs, + failure reasons, and cause details, so applications can classify failures + consistently before and after caching. + [[#1168], [#1237] by Junghoon Ban\] -[#828]: https://github.com/fedify-dev/fedify/issues/828 -[#831]: https://github.com/fedify-dev/fedify/issues/831 -[#833]: https://github.com/fedify-dev/fedify/issues/833 -[#912]: https://github.com/fedify-dev/fedify/issues/912 -[#913]: https://github.com/fedify-dev/fedify/pull/913 -[#924]: https://github.com/fedify-dev/fedify/pull/924 -[#935]: https://github.com/fedify-dev/fedify/pull/935 -[#1074]: https://github.com/fedify-dev/fedify/pull/1074 -[#1152]: https://github.com/fedify-dev/fedify/issues/1152 -[#1182]: https://github.com/fedify-dev/fedify/pull/1182 +### @fedify/vocab -### @fedify/vocab-tools + - Fixed language-tagged strings and `Link.hreflang` values with extended + language subtags being dropped or rejected. Tags such as `zh-YUE`, which + Mastodon uses for Cantonese posts, are now accepted and parsed into their + canonical form (`yue`). Unmodified objects still re-emit the original + tag through the cached JSON-LD document, but objects serialized afresh + use the canonical tag. + [[#1229], [#1231]] + - Fixed shared property arrays in vocabulary objects so dereferencing a + clone no longer changes its source's properties or serialization. + Constructors and `clone()` also copy supplied plural-value arrays, + allowing frozen arrays and preventing changes to the caller's arrays. + Nested objects and URLs retain their identity. + [[#1207], [#1208]] - - Added the `extraContext` property schema option to include a JSON-LD - context only when its terms are used, preserving existing output for - objects without the extension. [[#1037], [#1038]] - - Added the `trustEmbeddedObjects` type schema option so embedded metadata - identifiers need not establish trust in linked actors. [[#1037], [#1038]] - - Changed suppressed vocabulary fetch and parsing failures to log at the - warning level so that intentionally handled failures are not reported as - application errors. [[#933], [#1035] by Jae Hui Hong\] +### @fedify/vocab-runtime -[#933]: https://github.com/fedify-dev/fedify/issues/933 -[#1035]: https://github.com/fedify-dev/fedify/pull/1035 + - Added the [FEP-22cd] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents with translation metadata from being parsed or verified. + [[#1214], [#1227]] + - Added the [FEP-6757] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents from being parsed or verified. + [[#1211], [#1216]] + - Fixed the `LanguageString` constructor throwing a `RangeError` for + language tags with extended language subtags, such as `zh-YUE`. It now + stores their canonical form (`yue`) as its `locale`. + [[#1229], [#1231]] + - Fixed the preloaded Mastodon context treating `attributionDomains` values + as IRIs instead of plain domain name strings. Values now expand as string + literals and remain arrays when compacted, matching Mastodon's context. + [[#1233], [#1235]] + +[#1214]: https://github.com/fedify-dev/fedify/issues/1214 +[#1227]: https://github.com/fedify-dev/fedify/pull/1227 + +### @fedify/vocab-tools + + - Fixed generated constructors and `clone()` methods to copy property + arrays, preventing a clone's remote lookups from changing its source and + preserving arrays supplied by callers. + [[#1207], [#1208]] + - Fixed generated vocabulary code rejecting language tags with extended + language subtags, such as `zh-YUE`, in language-tagged strings and + `fedify:langTag` values. Code generated by this version requires + a release of *@fedify/vocab-runtime* that includes this fix. + [[#1229], [#1231]] Version 2.3.10 @@ -2831,6 +3312,245 @@ Released on June 25, 2026. [#756]: https://github.com/fedify-dev/fedify/pull/756 +Version 2.2.19 +-------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` accepting URLs with a username + or password. Such URLs now throw `UrlError` before fetching on all + runtimes, including redirect and alternate document targets, regardless + of `allowPrivateAddress`. + [[#1294], [#1295]] + - Fixed key ownership checks accepting another actor's document when + resolving an actor ID. When an actor ID redirected to, or directly + served, the document of a different actor, keys listed by that other + actor were attributed to the requested one, so an attacker whose actor + document was reached through a victim's actor ID could sign activities + that passed authentication as the victim's. This affected HTTP + Signatures, Linked Data Signatures, and Object Integrity Proofs. Fetched + actor documents are now accepted only when their `id` is exactly the + requested actor ID. \[[GHSA-8863-8468-7hq7]] + - As a consequence of the above fix, an actor whose ID redirects to + a document with a different `id`, even on the same origin and for the + same account, can no longer have its keys confirmed through that + redirect. Servers that normalize actor URLs should use the canonical + actor ID, which responds without redirecting, as the `owner` or + `controller` of their keys and as the `actor` of their activities. + [[GHSA-8863-8468-7hq7]] + - The built-in `KvStore`-backed key cache now uses a new generation, so + keys cached under the weaker check are fetched and verified again after + upgrading. Applications with a custom `KeyCache` implementation should + clear it once when upgrading. \[[GHSA-8863-8468-7hq7]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` accepting URLs with a username or password. + Such URLs now throw `UrlError` before fetching on all runtimes, including + redirect and alternate document targets, regardless of + `allowPrivateAddress`. + [[#1294], [#1295]] + + +Version 2.2.18 +-------------- + +Released on October 11, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. + [[#1292], [#1293]] + - Fixed inboxes accepting signed activities whose IDs had a different origin + from their actors. Such deliveries now receive `401 Unauthorized` before + they can supply trusted embedded objects or reserve the genuine activity's + idempotency entry. Queued and manually routed activities apply the same + origin check. \[[GHSA-4qhx-hhwf-8685]] + +### @fedify/cli + + - Fixed `fedify webfinger` ignoring `-p`/`--allow-private-address` and the + `webfinger.allowPrivateAddress` configuration setting, preventing lookups + against local development servers even with an explicit opt-in. Private + addresses remain blocked by default. + [[#1274], [#1276]] + +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nuxt + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when no + application route or Nuxt page could serve a representation. Renderer + 404s are now converted back to 406 before Nitro's error handler runs, + so these responses do not reach Nitro's error handler or error hooks. + Deliberate 404s from matched server routes are preserved. + [[#1278], [#1279]] + +### @fedify/solidstart + + - Fixed deferred `406 Not Acceptable` responses becoming 404 when an + application returned a `Response` with status 404, returned no response, + or threw a 404 error. Responses with other statuses are preserved. The + 406 is restored for buffered 404 responses before headers are sent; + responses whose headers have already been sent cannot be changed. Thrown + errors still reach Nitro's error hooks. [[#1278], [#1279]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. + [[#1292], [#1293]] + + +Version 2.2.17 +-------------- + +Released on October 9, 2026. + +### @fedify/fedify + + - Fixed signature verification throwing an exception when a remote actor + supplied a malformed JSON-LD context or its context could not be fetched. + These requesters are now treated as unverifiable. + [[#1267], [#1270]] + +### @fedify/cfworkers + + - Fixed npm refusing to install `@fedify/cfworkers` alongside + `@cloudflare/workers-types` 5.x, which recent versions of Wrangler + require, unless `--legacy-peer-deps` or an override was used. The peer + dependency on `@cloudflare/workers-types` now accepts both 4.x and 5.x, + so you can drop such workarounds. + [[#1255], [#1260]] + +### @fedify/express + + - Fixed asynchronous `contextDataFactory` and `federation.fetch()` failures + being left as unhandled rejections in the Express integration. Errors + that occur before Fedify passes the request to the next middleware now + reach Express error-handling middleware, so applications can send their + usual error response. + [[#1244], [#1261]] + +### @fedify/postgres + + - Fixed `PostgresMessageQueue` remaining unusable after a transient + initialization failure. Later calls now retry initialization, so the same + queue instance can recover when the database becomes available again. + [[#1268], [#1271]] + + +Version 2.2.16 +-------------- + +Released on October 5, 2026. + +### @fedify/fedify + + - Fixed cached signing-key fetch failures losing their error types, URLs, + failure reasons, and cause details, so applications can classify failures + consistently before and after caching. + [[#1168], [#1237] by Junghoon Ban\] + +### @fedify/vocab + + - Fixed language-tagged strings and `Link.hreflang` values with extended + language subtags being dropped or rejected. Tags such as `zh-YUE`, which + Mastodon uses for Cantonese posts, are now accepted and parsed into their + canonical form (`yue`). Unmodified objects still re-emit the original + tag through the cached JSON-LD document, but objects serialized afresh + use the canonical tag. + [[#1229], [#1231]] + - Fixed shared property arrays in vocabulary objects so dereferencing a + clone no longer changes its source's properties or serialization. + Constructors and `clone()` also copy supplied plural-value arrays, + allowing frozen arrays and preventing changes to the caller's arrays. + Nested objects and URLs retain their identity. + [[#1207], [#1208]] + +### @fedify/vocab-runtime + + - Added the [FEP-22cd] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents with translation metadata from being parsed or verified. + [[#1214], [#1227]] + - Added the [FEP-6757] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents from being parsed or verified. + [[#1211], [#1216]] + - Fixed the `LanguageString` constructor throwing a `RangeError` for + language tags with extended language subtags, such as `zh-YUE`. It now + stores their canonical form (`yue`) as its `locale`. + [[#1229], [#1231]] + - Fixed the preloaded Mastodon context treating `attributionDomains` values + as IRIs instead of plain domain name strings. Values now expand as string + literals and remain arrays when compacted, matching Mastodon's context. + [[#1233], [#1235]] + +### @fedify/vocab-tools + + - Fixed generated constructors and `clone()` methods to copy property + arrays, preventing a clone's remote lookups from changing its source and + preserving arrays supplied by callers. + [[#1207], [#1208]] + - Fixed generated vocabulary code rejecting language tags with extended + language subtags, such as `zh-YUE`, in language-tagged strings and + `fedify:langTag` values. Code generated by this version requires + a release of *@fedify/vocab-runtime* that includes this fix. + [[#1229], [#1231]] + + Version 2.2.15 -------------- @@ -3729,6 +4449,226 @@ Released on April 28, 2026. [#722]: https://github.com/fedify-dev/fedify/pull/722 +Version 2.1.30 +-------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` accepting URLs with a username + or password. Such URLs now throw `UrlError` before fetching on all + runtimes, including redirect and alternate document targets, regardless + of `allowPrivateAddress`. + [[#1294], [#1295]] + - Fixed key ownership checks accepting another actor's document when + resolving an actor ID. When an actor ID redirected to, or directly + served, the document of a different actor, keys listed by that other + actor were attributed to the requested one, so an attacker whose actor + document was reached through a victim's actor ID could sign activities + that passed authentication as the victim's. This affected HTTP + Signatures, Linked Data Signatures, and Object Integrity Proofs. Fetched + actor documents are now accepted only when their `id` is exactly the + requested actor ID. \[[GHSA-8863-8468-7hq7]] + - As a consequence of the above fix, an actor whose ID redirects to + a document with a different `id`, even on the same origin and for the + same account, can no longer have its keys confirmed through that + redirect. Servers that normalize actor URLs should use the canonical + actor ID, which responds without redirecting, as the `owner` or + `controller` of their keys and as the `actor` of their activities. + [[GHSA-8863-8468-7hq7]] + - The built-in `KvStore`-backed key cache now uses a new generation, so + keys cached under the weaker check are fetched and verified again after + upgrading. Applications with a custom `KeyCache` implementation should + clear it once when upgrading. \[[GHSA-8863-8468-7hq7]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` accepting URLs with a username or password. + Such URLs now throw `UrlError` before fetching on all runtimes, including + redirect and alternate document targets, regardless of + `allowPrivateAddress`. + [[#1294], [#1295]] + + +Version 2.1.29 +-------------- + +Released on October 11, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. + [[#1292], [#1293]] + - Fixed inboxes accepting signed activities whose IDs had a different origin + from their actors. Such deliveries now receive `401 Unauthorized` before + they can supply trusted embedded objects or reserve the genuine activity's + idempotency entry. Queued and manually routed activities apply the same + origin check. \[[GHSA-4qhx-hhwf-8685]] + +### @fedify/cli + + - Fixed `fedify webfinger` ignoring `-p`/`--allow-private-address` and the + `webfinger.allowPrivateAddress` configuration setting, preventing lookups + against local development servers even with an explicit opt-in. Private + addresses remain blocked by default. + [[#1274], [#1276]] + +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. + [[#1292], [#1293]] + + +Version 2.1.28 +-------------- + +Released on October 9, 2026. + +### @fedify/fedify + + - Fixed signature verification throwing an exception when a remote actor + supplied a malformed JSON-LD context or its context could not be fetched. + These requesters are now treated as unverifiable. + [[#1267], [#1270]] + +### @fedify/cfworkers + + - Fixed npm refusing to install `@fedify/cfworkers` alongside + `@cloudflare/workers-types` 5.x, which recent versions of Wrangler + require, unless `--legacy-peer-deps` or an override was used. The peer + dependency on `@cloudflare/workers-types` now accepts both 4.x and 5.x, + so you can drop such workarounds. + [[#1255], [#1260]] + +### @fedify/express + + - Fixed asynchronous `contextDataFactory` and `federation.fetch()` failures + being left as unhandled rejections in the Express integration. Errors + that occur before Fedify passes the request to the next middleware now + reach Express error-handling middleware, so applications can send their + usual error response. + [[#1244], [#1261]] + +### @fedify/postgres + + - Fixed `PostgresMessageQueue` remaining unusable after a transient + initialization failure. Later calls now retry initialization, so the same + queue instance can recover when the database becomes available again. + [[#1268], [#1271]] + + +Version 2.1.27 +-------------- + +Released on October 5, 2026. + +### @fedify/fedify + + - Fixed cached signing-key fetch failures losing their error types, URLs, + failure reasons, and cause details, so applications can classify failures + consistently before and after caching. [[#1168], [#1237] by Junghoon Ban\] + +### @fedify/vocab + + - Fixed language-tagged strings and `Link.hreflang` values with extended + language subtags being dropped or rejected. Tags such as `zh-YUE`, which + Mastodon uses for Cantonese posts, are now accepted and parsed into their + canonical form (`yue`). Unmodified objects still re-emit the original + tag through the cached JSON-LD document, but objects serialized afresh + use the canonical tag. + [[#1229], [#1231]] + - Fixed shared property arrays in vocabulary objects so dereferencing a + clone no longer changes its source's properties or serialization. + Constructors and `clone()` also copy supplied plural-value arrays, + allowing frozen arrays and preventing changes to the caller's arrays. + Nested objects and URLs retain their identity. + [[#1207], [#1208]] + +### @fedify/vocab-runtime + + - Added the [FEP-22cd] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents with translation metadata from being parsed or verified. + [[#1214], [#1227]] + - Added the [FEP-6757] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents from being parsed or verified. + [[#1211], [#1216]] + - Fixed the `LanguageString` constructor throwing a `RangeError` for + language tags with extended language subtags, such as `zh-YUE`. It now + stores their canonical form (`yue`) as its `locale`. + [[#1229], [#1231]] + - Fixed the preloaded Mastodon context treating `attributionDomains` values + as IRIs instead of plain domain name strings. Values now expand as string + literals and remain arrays when compacted, matching Mastodon's context. + [[#1233], [#1235]] + +### @fedify/vocab-tools + + - Fixed generated constructors and `clone()` methods to copy property + arrays, preventing a clone's remote lookups from changing its source and + preserving arrays supplied by callers. + [[#1207], [#1208]] + - Fixed generated vocabulary code rejecting language tags with extended + language subtags, such as `zh-YUE`, in language-tagged strings and + `fedify:langTag` values. Code generated by this version requires + a release of *@fedify/vocab-runtime* that includes this fix. + [[#1229], [#1231]] + + Version 2.1.26 -------------- @@ -4769,6 +5709,207 @@ Released on March 24, 2026. [#599]: https://github.com/fedify-dev/fedify/pull/599 +Version 2.0.34 +-------------- + +Released on October 12, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` accepting URLs with a username + or password. Such URLs now throw `UrlError` before fetching on all + runtimes, including redirect and alternate document targets, regardless + of `allowPrivateAddress`. [[#1294], [#1295]] + - Fixed key ownership checks accepting another actor's document when + resolving an actor ID. When an actor ID redirected to, or directly + served, the document of a different actor, keys listed by that other + actor were attributed to the requested one, so an attacker whose actor + document was reached through a victim's actor ID could sign activities + that passed authentication as the victim's. This affected HTTP + Signatures, Linked Data Signatures, and Object Integrity Proofs. Fetched + actor documents are now accepted only when their `id` is exactly the + requested actor ID. \[[GHSA-8863-8468-7hq7]] + - As a consequence of the above fix, an actor whose ID redirects to + a document with a different `id`, even on the same origin and for the + same account, can no longer have its keys confirmed through that + redirect. Servers that normalize actor URLs should use the canonical + actor ID, which responds without redirecting, as the `owner` or + `controller` of their keys and as the `actor` of their activities. + [[GHSA-8863-8468-7hq7]] + - The built-in `KvStore`-backed key cache now uses a new generation, so + keys cached under the weaker check are fetched and verified again after + upgrading. Applications with a custom `KeyCache` implementation should + clear it once when upgrading. \[[GHSA-8863-8468-7hq7]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` accepting URLs with a username or password. + Such URLs now throw `UrlError` before fetching on all runtimes, including + redirect and alternate document targets, regardless of + `allowPrivateAddress`. [[#1294], [#1295]] + + +Version 2.0.33 +-------------- + +Released on October 11, 2026. + +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. [[#1292], [#1293]] + - Fixed inboxes accepting signed activities whose IDs had a different origin + from their actors. Such deliveries now receive `401 Unauthorized` before + they can supply trusted embedded objects or reserve the genuine activity's + idempotency entry. Queued and manually routed activities apply the same + origin check. \[[GHSA-4qhx-hhwf-8685]] + +### @fedify/cli + + - Fixed `fedify webfinger` ignoring `-p`/`--allow-private-address` and the + `webfinger.allowPrivateAddress` configuration setting, preventing lookups + against local development servers even with an explicit opt-in. Private + addresses remain blocked by default. [[#1274], [#1276]] + +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. [[#1292], [#1293]] + + +Version 2.0.32 +-------------- + +Released on October 9, 2026. + +### @fedify/fedify + + - Fixed signature verification throwing an exception when a remote actor + supplied a malformed JSON-LD context or its context could not be fetched. + These requesters are now treated as unverifiable. [[#1267], [#1270]] + +### @fedify/cfworkers + + - Fixed npm refusing to install `@fedify/cfworkers` alongside + `@cloudflare/workers-types` 5.x, which recent versions of Wrangler + require, unless `--legacy-peer-deps` or an override was used. The peer + dependency on `@cloudflare/workers-types` now accepts both 4.x and 5.x, + so you can drop such workarounds. [[#1255], [#1260]] + +### @fedify/express + + - Fixed asynchronous `contextDataFactory` and `federation.fetch()` failures + being left as unhandled rejections in the Express integration. Errors + that occur before Fedify passes the request to the next middleware now + reach Express error-handling middleware, so applications can send their + usual error response. [[#1244], [#1261]] + +### @fedify/postgres + + - Fixed `PostgresMessageQueue` remaining unusable after a transient + initialization failure. Later calls now retry initialization, so the same + queue instance can recover when the database becomes available again. + [[#1268], [#1271]] + + +Version 2.0.31 +-------------- + +Released on October 5, 2026. + +### @fedify/vocab + + - Fixed language-tagged strings and `Link.hreflang` values with extended + language subtags being dropped or rejected. Tags such as `zh-YUE`, which + Mastodon uses for Cantonese posts, are now accepted and parsed into their + canonical form (`yue`). Unmodified objects still re-emit the original + tag through the cached JSON-LD document, but objects serialized afresh + use the canonical tag. [[#1229], [#1231]] + - Fixed shared property arrays in vocabulary objects so dereferencing a + clone no longer changes its source's properties or serialization. + Constructors and `clone()` also copy supplied plural-value arrays, + allowing frozen arrays and preventing changes to the caller's arrays. + Nested objects and URLs retain their identity. [[#1207], [#1208]] + +### @fedify/vocab-runtime + + - Added the [FEP-22cd] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents with translation metadata from being parsed or verified. + [[#1214], [#1227]] + - Added the [FEP-6757] context to the preloaded JSON-LD contexts. The default + document loader now resolves locally, so + transient Codeberg Pages outages no longer prevent otherwise valid inbound + documents from being parsed or verified. [[#1211], [#1216]] + - Fixed the `LanguageString` constructor throwing a `RangeError` for + language tags with extended language subtags, such as `zh-YUE`. It now + stores their canonical form (`yue`) as its `locale`. [[#1229], [#1231]] + - Fixed the preloaded Mastodon context treating `attributionDomains` values + as IRIs instead of plain domain name strings. Values now expand as string + literals and remain arrays when compacted, matching Mastodon's context. + [[#1233], [#1235]] + +### @fedify/vocab-tools + + - Fixed generated constructors and `clone()` methods to copy property + arrays, preventing a clone's remote lookups from changing its source and + preserving arrays supplied by callers. [[#1207], [#1208]] + - Fixed generated vocabulary code rejecting language tags with extended + language subtags, such as `zh-YUE`, in language-tagged strings and + `fedify:langTag` values. Code generated by this version requires + a release of *@fedify/vocab-runtime* that includes this fix. + [[#1229], [#1231]] + + Version 2.0.30 -------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d96fe11..15d201de8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -534,8 +534,9 @@ A bug-fix pull request should include: - A regression test that fails without the patch and passes with it. - The fix. - - A Sacho changelog fragment with the issue, pull request, and contributor - name, unless the contributor wants to remain anonymous. + - A Sacho changelog fragment for a user-visible change, with both issue and + pull request references and the external contributor's name. See + “Changelog entries” below for the requirements. Link the accepted issue in the pull request. @@ -607,12 +608,37 @@ Assisted-by: AGENT_NAME:MODEL_VERSION Use one trailer for each tool. Do not use `Co-authored-by` for AI assistance. +For `MODEL_VERSION`, identify the model actually used and look up its official +identifier in the model maker's documentation, using a web search if needed. +Use that identifier, or the documented name of that model if no identifier is +available, including any variant or version needed to distinguish the model. +Do not invent an identifier or guess it from the tool's name, a model family, +or an old example. + ### Changelog entries Fedify uses [Sacho] to build *CHANGES.md* from Markdown fragments under -*changes.d/*. Do not edit the unreleased part of *CHANGES.md* directly. -Create a fragment for each user-visible change, selecting the affected package -and using a topic-based name rather than an issue or pull request number: +*changes.d/*. This repository sets `materialize = true`, so Sacho regenerates +the unreleased region of *CHANGES.md* from the fragments. A pull request that +adds or updates a fragment therefore changes *CHANGES.md* as a generated side +effect, and that is expected. The fragments remain the source of truth: never +hand-edit the generated unreleased region of *CHANGES.md*; edit the fragment +instead and let Sacho refresh the region. + +#### When to add a fragment + +Add a fragment only when Fedify users can observe a change, for example through +the public API or CLI. Internal refactoring, fixes to test code, and tests +added for coverage must not have changelog entries unless the same change also +affects behavior users can observe. For changes with no user-visible effect, +omit the fragment and add this trailer to the commit message: + +~~~~ +Changelog: none +~~~~ + +For each user-visible change, create a fragment for the affected package using +a topic-based name rather than an issue or pull request number: ~~~~ bash sacho add --section @fedify/fedify clearer-errors @@ -626,17 +652,60 @@ development history out of the entry. If the change evolves before release, update the existing fragment so that it describes only the behavior users will receive. -Add issue and pull request references at the end of the first paragraph using -shortcut links. Include the accepted issue when available, and add the pull -request number before merging: +#### Issue and pull request links -~~~~ markdown - - Fixed a bug where foo would bar. [[#123]] -~~~~ +Every changelog entry must include both the accepted issue number and the pull +request number at the end of its first paragraph, using shortcut links: -For an external contributor, add their name after the marker, for example -`[[#123] by John Doe]`, unless they want to remain anonymous. Sacho generates -the corresponding link definitions. +~~~~ markdown + - Fixed a bug where foo would bar. [[#123], [#456] by John Doe] +~~~~ + +Before opening a pull request, its number is not yet known. Use either of +these workflows: + +1. Predict the number if you will open the pull request immediately. Find + the highest issue, pull request, or discussion number in + `fedify-dev/fedify` and add one. First save the fragment with only the + issue reference and run `sacho resolve-links` to create its `links` + frontmatter. Then manually add the predicted PR number to both that + metadata and the entry's trailing references. Use + `https://github.com/fedify-dev/fedify/pull/` for the predicted URL. + For example, if the issue is #123 and the predicted PR is #456: + + ~~~~ yaml + links: + '#123': https://github.com/fedify-dev/fedify/issues/123 + '#456': https://github.com/fedify-dev/fedify/pull/456 + ~~~~ + + Open the PR, verify its actual number, and correct both the metadata and + the entry if the prediction was wrong. Another contributor may have + taken the predicted number in the meantime. + +2. Open the pull request with only the issue reference in the fragment. + Then add the actual PR number to the entry and run `sacho resolve-links` + to record its URL in the `links` frontmatter. Amend the commit with the + updated fragment and push the amended commit to the PR branch with + `--force-with-lease`. + +`sacho resolve-links` pins reference URLs so that Sacho can generate the +corresponding link definitions. Review its diff, including any changes to +other fragments and the generated *CHANGES.md*. Whichever workflow you use, +both references must be present before merging. + +#### Contributor names + +External contributors must include their name after the references, as in +`[[#123], [#456] by John Doe]`. Maintainers may also include their name. +Prefer your real name, or a close rendering of it in Latin letters, rather +than copying your GitHub handle. A pseudonym is welcome if that is how you +want to be credited. Normally capitalize the first letter of each part of +the name, as in “John Doe.” These naming conventions are guidelines; use +your preferred spelling and capitalization if you have a deliberate +preference. + +#### Checking fragments Keep the fragment on the same branch and in the same commit series as the change. Format it, preview the compiled section, and check it before @@ -648,14 +717,6 @@ sacho preview --section @fedify/fedify sacho check ~~~~ -Changes with no user-visible effect, such as internal refactoring or test-only -work, do not need a fragment. If Sacho's changed-path check requires one, add -this trailer to the commit message: - -~~~~ -Changelog: none -~~~~ - [Sacho]: https://sacho.dev/guide/everyday-workflow ### Version bumps diff --git a/FEDERATION.md b/FEDERATION.md index 1f6325865..d698cbf30 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -30,6 +30,7 @@ Supported FEPs - [FEP-9091][]: Export Actor Service Endpoint - [FEP-f1d5][]: NodeInfo in Fediverse Software - [FEP-8b32][]: Object Integrity Proofs + - [FEP-ef61][]: Portable objects - [FEP-521a][]: Representing actor's public keys - [FEP-5feb][]: Search indexing consent for actors - [FEP-fe34][]: Origin-based security model @@ -39,6 +40,7 @@ Supported FEPs - [FEP-044f][]: Consent-respecting quote posts - [FEP-7aa9][]: Featuring recommendations using a dedicated collection - [FEP-22cd][]: Attributing translations + - [FEP-6757][]: Content license metadata - [FEP-0837][]: Federated Marketplace - [FEP-ae0c][]: Fediverse Relay Protocols: Mastodon and LitePub - [FEP-ef61][]: Portable Objects (partial; see [below][FEP-ef61 section]) @@ -49,6 +51,7 @@ Supported FEPs [FEP-9091]: https://w3id.org/fep/9091 [FEP-f1d5]: https://w3id.org/fep/f1d5 [FEP-8b32]: https://w3id.org/fep/8b32 +[FEP-ef61]: https://w3id.org/fep/ef61 [FEP-521a]: https://w3id.org/fep/521a [FEP-5feb]: https://w3id.org/fep/5feb [FEP-fe34]: https://w3id.org/fep/fe34 @@ -58,9 +61,9 @@ Supported FEPs [FEP-044f]: https://w3id.org/fep/044f [FEP-7aa9]: https://w3id.org/fep/7aa9 [FEP-22cd]: https://w3id.org/fep/22cd +[FEP-6757]: https://w3id.org/fep/6757 [FEP-0837]: https://w3id.org/fep/0837 [FEP-ae0c]: https://w3id.org/fep/ae0c -[FEP-ef61]: https://w3id.org/fep/ef61 [FEP-ef61 section]: #fep-ef61 @@ -83,14 +86,86 @@ current FEP text in two ways, which may change in Fedify 3.0: Fedify does not implement the [FEP-ae97] gateway endpoints, gateway discovery, synchronization across gateways, key rotation, built-in resolution of DID methods other than `did:key`, or gateways with paths. -Interoperability with other implementations is still being tested. See the -[*Portable objects* chapter][portable objects] of the manual for the full -profile. +Its interoperability with other implementations is described in the next +section. See the [*Portable objects* chapter][portable objects] of the manual +for the full profile. [FEP-ae97]: https://w3id.org/fep/ae97 [portable objects]: https://fedify.dev/manual/portable +FEP-ef61 interoperability +------------------------- + +Fedify accepts the portable `Application` actor captured from tootik v0.25.4 +(commit `5ce4b7fe074d7c385808be387ffcda7040842a19`), including its HTTPS +compatible identifier and `eddsa-jcs-2022` Object Integrity Proof. The actor +omits the JSON-LD mapping for `gateways`; Fedify reads that list and requires +JCS proofs to authenticate the original JSON. The unchanged response and +capture procedure are recorded in the [tootik fixture]. + +In a separate local HTTPS test, a freshly registered portable tootik `Person` +sent a `Follow` to a Fedify inbox. Fedify independently verified the original +Cavage RSA HTTP Signature, returned HTTP 202, and dispatched the activity to +the `Follow` listener. The inbox authenticated the activity's original +`eddsa-jcs-2022` proof; proof-authenticated delivery does not itself check the +HTTP Signature. The exact request, actor response, and procedure are recorded +in the [tootik Follow fixture]. Offline replay uses the captured time to check +the HTTP Signature's freshness; the live request used the normal current-time +check. + +In the reverse direction, tootik v0.25.4 accepted a Fedify-generated portable +`Create` and stored its embedded `Note` with the expected ID, author, and +content. Fedify generated the actor and object proofs and sent the activity +through `Context.sendActivity()`. The first request received HTTP 401 with +`actor is too young`; the automatic retry received HTTP 202, followed by +database confirmation of processing. Changing the embedded note's content +without updating its proofs received HTTP 401. The [Fedify to tootik fixture] +records the original output, responses, and stored note. + +This reverse test used HTTPS gateway origins without explicit ports: tootik +v0.25.4 does not recognize compatible actor IDs with an explicit port. Its +inbox authenticated the activity through its proof and skipped HTTP Signature +verification. The request carried a gateway RSA signature, but this result +does not establish independent acceptance of that signature by tootik. + +Mitra v5.10.0 (commit `17340ef09d4a902f301acdcfde094f5778d72f35`) accepted +a Fedify-generated portable actor registration and processed its setup +activities. A separate portable Fedify sender, which had no registered Mitra +account, then delivered a `Create` to the registered actor's inbox. This used +native Fedify objects, default signing contexts, and `Context.sendActivity()` +with `normalizeExistingProofs: true`. Mitra returned HTTP 202 on the first RFC +9421 RSA request and its worker stored the sender's activity in the recipient's +inbox. Corrupting the HTTP Signature returned HTTP 401. This required +serializing `gateways` as origins without a trailing slash; Mitra rejected the +previous spelling with `invalid gateway URL`. + +Gateway registration used a separate scratch client for Mitra's FEP-ae97 +endpoint; Fedify does not provide that client. Its setup `Update` used a +compact context and Fedify's existing internal outgoing normalizer because +Mitra's embedded-object limit rejected the default actor context. This setup +workaround was separate from the native server-to-server delivery. The +[Fedify to Mitra fixture] records the results and limitations, including the +portless HTTPS gateway used for the sender because Mitra's WebFinger lookup +drops explicit gateway ports. + +In the other direction, Mitra forwarded a portable `Create` submitted to its +gateway outbox by the Fedify client. Mitra signed the HTTP request with its +Cavage RSA gateway key; the activity and embedded note retained the client's +DID proofs. Fedify separately verified the HTTP Signature with the normal +current-time freshness check, returned HTTP 202 from its proof-authenticated +inbox, and dispatched the `Create` listener. The [Mitra to Fedify fixture] +records this delivery. It tests Mitra's gateway transport of client-signed +objects, rather than independent generation of DID proofs by Mitra. +Mastodon compatibility remains unverified. + +[tootik fixture]: packages/fedify/test-vectors/fep-ef61/tootik-v0.25.4/README.md +[tootik Follow fixture]: packages/fedify/test-vectors/fep-ef61/tootik-v0.25.4/follow/README.md +[Fedify to tootik fixture]: packages/fedify/test-vectors/fep-ef61/tootik-v0.25.4/send/README.md +[Fedify to Mitra fixture]: packages/fedify/test-vectors/fep-ef61/mitra-v5.10.0/send/README.md +[Mitra to Fedify fixture]: packages/fedify/test-vectors/fep-ef61/mitra-v5.10.0/receive/README.md + + ActivityPub ----------- diff --git a/SPONSORS.md b/SPONSORS.md index a10c324ab..e4ba0ba72 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -32,12 +32,12 @@ Supporters Backers ------- -Robin Riley, Encyclia, box464, Evan Prodromou, taye, okin, yamanoku, malte, Rafael Goulart, Andy Piper +Encyclia, Robin Riley, box464, Evan Prodromou, taye, okin, yamanoku, malte, Rafael Goulart, Andy Piper One-time donations ------------------ -Robin Riley, malte, Nils Bergmann, Markus P, Rameez, Guest, Guest, Guest, Guest, Guest, Guest, Guest, Guest +Robin Riley, malte, Nils Bergmann, Markus P, Rameez, Guest, Guest, 1, Guest, Guest, Guest, Guest, Guest, Guest, Guest, Guest, Guest diff --git a/changes.d/adonisjs/adonisjs-integration.md b/changes.d/adonisjs/adonisjs-integration.md deleted file mode 100644 index 1989f907b..000000000 --- a/changes.d/adonisjs/adonisjs-integration.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1006': https://github.com/fedify-dev/fedify/pull/1006 - '#139': https://github.com/fedify-dev/fedify/issues/139 ---- - - Added the new *@fedify/adonisjs* package, an integration for the AdonisJS - framework. It provides a server middleware that mounts a `Federation` - inside an AdonisJS application, a service provider that owns the - federation's lifecycle, a `node ace configure` hook that scaffolds - *config/fedify.ts* and the federation preload files, and a `ctx.federation` - request context. The package targets Node.js and is published to npm only. - [[#139], [#1006] by Samuel Brinkmann] diff --git a/changes.d/astro/imported-unreleased.md b/changes.d/astro/imported-unreleased.md deleted file mode 100644 index 52c319300..000000000 --- a/changes.d/astro/imported-unreleased.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#931': https://github.com/fedify-dev/fedify/issues/931 - '#936': https://github.com/fedify-dev/fedify/pull/936 ---- - - Added and continuously tested support for Astro 6 and 7 while retaining - Astro 5 compatibility. The Astro example and `fedify init` templates now - use Astro 7 with current Node.js and Deno adapters; Bun uses the tested - `@astrojs/node` standalone output instead of the Astro-5-only - `@nurodev/astro-bun` adapter. - [[#931], [#936]] diff --git a/changes.d/cli/imported-unreleased.md b/changes.d/cli/imported-unreleased.md deleted file mode 100644 index 837c2da1f..000000000 --- a/changes.d/cli/imported-unreleased.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 - '#940': https://github.com/fedify-dev/fedify/pull/940 ---- - - Added `fedify.com.es` as a tunneling service. The CLI pins the service's - SSH host key and rejects a mismatched server before exposing a local port. - [[#940]] - - Removed `localhost.run` as a tunneling service. The service is no longer - available, and the CLI now rejects attempts to use it. - [[#940]] - - Switched the CLI's Temporal runtime dependency from - `@js-temporal/polyfill` to `temporal-polyfill`. - [[#823], [#925]] diff --git a/changes.d/cli/improve-favicon-selection.md b/changes.d/cli/improve-favicon-selection.md new file mode 100644 index 000000000..af631d7fe --- /dev/null +++ b/changes.d/cli/improve-favicon-selection.md @@ -0,0 +1,11 @@ +--- +links: + '#1187': https://github.com/fedify-dev/fedify/pull/1187 + '#893': https://github.com/fedify-dev/fedify/issues/893 +--- + - Changed the `fedify nodeinfo` favicon selector to pick a usable bitmap + icon more often. It now skips SVG icons declared with + `type="image/svg+xml"`, not just those with a `.svg` URL, and it considers + every declared size instead of only the first, so an icon offering a large + size is no longer discarded because its smallest size is too small. + [[#893], [#1187] by Lee Jeongmin] diff --git a/changes.d/cli/init-sveltekit.md b/changes.d/cli/init-sveltekit.md deleted file mode 100644 index fdac414cb..000000000 --- a/changes.d/cli/init-sveltekit.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#892': https://github.com/fedify-dev/fedify/issues/892 - '#971': https://github.com/fedify-dev/fedify/pull/971 ---- - - Added [SvelteKit] option to `fedify init` command. This option allows users - to initialize a new Fedify project with SvelteKit integration. - [[#892], [#971] by Jang Hanarae] diff --git a/changes.d/cli/lookup-default-timeout.md b/changes.d/cli/lookup-default-timeout.md deleted file mode 100644 index 634713055..000000000 --- a/changes.d/cli/lookup-default-timeout.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1131': https://github.com/fedify-dev/fedify/issues/1131 - '#1169': https://github.com/fedify-dev/fedify/pull/1169 ---- - - Changed `fedify lookup` to time out each request after 10 seconds when - the `-T`/`--timeout` option is not given, since the document loaders it - uses now have a default timeout. Previously, there was no timeout by - default. The `-T`/`--timeout` option now also limits how long - a request waits for a DNS lookup, though it cannot stop the lookup - itself. [[#1131], [#1169]] diff --git a/changes.d/cli/optique-1.3.2.md b/changes.d/cli/optique-1.3.2.md deleted file mode 100644 index 4f2ed06bf..000000000 --- a/changes.d/cli/optique-1.3.2.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -links: - '#1197': https://github.com/fedify-dev/fedify/pull/1197 ---- - - Updated Optique to 1.3.2. This fixes several command-line parsing - issues, so options with attached values such as `--timeout=30` are - handled consistently, typo suggestions for mistyped options are more - accurate, and errors for known options are no longer hidden by - positional arguments before `--`. [[#1197]] diff --git a/changes.d/cli/portable-objects.md b/changes.d/cli/portable-objects.md deleted file mode 100644 index a02d2ca53..000000000 --- a/changes.d/cli/portable-objects.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -links: - '#1156': https://github.com/fedify-dev/fedify/issues/1156 - '#1199': https://github.com/fedify-dev/fedify/pull/1199 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added support for [FEP-ef61] portable objects to `fedify lookup`. It now - looks up portable objects by their `ap:` and `ap+ef61:` IDs, compatible - identifiers, and the WebFinger handles of portable actors, and verifies - their Object Integrity Proofs. Previously, - it failed to look up portable IDs, and refused portable objects found - through compatible identifiers as cross-origin objects. The same applies - to the collections that `-t`/`--traverse` traverses and the objects that - `--recurse` follows. When no gateway returns an acceptable object, the - command tells why, e.g., that the object's proof is invalid. - [[#288], [#1156], [#1199]] - - - Added the `--gateway` option to `fedify lookup`, `fedify inbox`, and - `fedify webfinger`, which specifies the FEP-ef61 gateways to look up - portable objects from, e.g., for portable IDs without `@gateway` location - hints. [[#288], [#1156], [#1199]] - - - Changed `fedify inbox -f`/`--follow` to follow FEP-ef61 portable actors, - and changed the `-a`/`--accept-follow` option of `fedify inbox` and the - `-a`/`--accept-follow` and `-r`/`--reject-follow` options of `fedify relay` - to accept portable IDs and compatible identifiers, which match the actor - regardless of `@gateway` location hints and the gateway of a compatible - identifier. [[#288], [#1156], [#1199]] - - - Changed `fedify webfinger` to accept FEP-ef61 portable actor IDs. As such - an ID does not tell which server to ask, the command looks up the actor and - then its WebFinger address, which consists of its `preferredUsername` and - the host of its first gateway, and reports whether the response links back - to the actor. [[#288], [#1156], [#1199]] - - - Fixed `fedify lookup --recurse` reporting a timeout or another network - failure of the first object or of a linked object as a possibly private - object, suggesting the `-a`/`--authorized-fetch` option. It now reports - the actual cause, e.g., “Request timed out after 10 seconds,” like the - other modes of `fedify lookup` do. [[#1156], [#1199]] - - - Fixed the `-p`/`--allow-private-address` option of `fedify webfinger` - being ignored. [[#1156], [#1199]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/cli/recursive-lookup-debug-errors.md b/changes.d/cli/recursive-lookup-debug-errors.md new file mode 100644 index 000000000..9fd680f97 --- /dev/null +++ b/changes.d/cli/recursive-lookup-debug-errors.md @@ -0,0 +1,7 @@ +--- +links: + '#1265': https://github.com/fedify-dev/fedify/pull/1265 + '#901': https://github.com/fedify-dev/fedify/issues/901 +--- + - Changed debug logs for suppressed recursive lookup errors to preserve the + original error as a cause alongside the failed target. [[#901], [#1265]] diff --git a/changes.d/cli/webfinger-lookup-error-detail.md b/changes.d/cli/webfinger-lookup-error-detail.md new file mode 100644 index 000000000..e06a65d0d --- /dev/null +++ b/changes.d/cli/webfinger-lookup-error-detail.md @@ -0,0 +1,9 @@ +--- +links: + '#1296': https://github.com/fedify-dev/fedify/pull/1296 + '#897': https://github.com/fedify-dev/fedify/issues/897 +--- + - Changed the `fedify webfinger` command's error message when a lookup + fails. It now explains why—resource not found, a network error, a + malformed response, or an unexpected server error—instead of always + reporting the resource as not found. [[#897], [#1296] by Jae-Hyuk-Jang] diff --git a/changes.d/debugger/debug-dashboard-filters.md b/changes.d/debugger/debug-dashboard-filters.md new file mode 100644 index 000000000..f01b35d1f --- /dev/null +++ b/changes.d/debugger/debug-dashboard-filters.md @@ -0,0 +1,11 @@ +--- +links: + '#1204': https://github.com/fedify-dev/fedify/pull/1204 + '#896': https://github.com/fedify-dev/fedify/issues/896 +--- + - Added filtering controls to the debug dashboard. The traces list can + now be narrowed down by activity type, and a trace's log table can be + narrowed down by category, level, or a free-text search of the message. + This makes it easier to find one failed activity or one noisy log + category once a federated app has produced more than a few traces. + [[#896], [#1204] by Jae-Hyuk-Jang] diff --git a/changes.d/debugger/imported-unreleased.md b/changes.d/debugger/imported-unreleased.md deleted file mode 100644 index 2c65df90b..000000000 --- a/changes.d/debugger/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS debugger build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/denokv/max-message-size.md b/changes.d/denokv/max-message-size.md new file mode 100644 index 000000000..f6edb2cbd --- /dev/null +++ b/changes.d/denokv/max-message-size.md @@ -0,0 +1,8 @@ +--- +links: + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - `DenoKvMessageQueue` now declares the `maxMessageSize` of Deno KV, so that + an incoming activity too large for Deno KV is processed immediately + instead of failing to be enqueued. [[#1290], [#1299]] diff --git a/changes.d/fedify/activity-origin-mismatch-reason.md b/changes.d/fedify/activity-origin-mismatch-reason.md new file mode 100644 index 000000000..d550c3378 --- /dev/null +++ b/changes.d/fedify/activity-origin-mismatch-reason.md @@ -0,0 +1,8 @@ + - Added the `activityOriginMismatch` reason to rejected `InboxAuthentication` + decisions in `onRequestFinished` reports. Inbox deliveries rejected + because the activity's ID does not have the same origin as its actors now + report this reason, along with the activity ID and actor IDs, instead of + `verificationFailed`. The `activitypub.verification.failure_reason` span + attribute uses the same value. \[[GHSA-4qhx-hhwf-8685]] + +[GHSA-4qhx-hhwf-8685]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-4qhx-hhwf-8685 diff --git a/changes.d/fedify/align-log-context-with-activity-spans.md b/changes.d/fedify/align-log-context-with-activity-spans.md new file mode 100644 index 000000000..3c4448401 --- /dev/null +++ b/changes.d/fedify/align-log-context-with-activity-spans.md @@ -0,0 +1,14 @@ +--- +links: + '#1030': https://github.com/fedify-dev/fedify/issues/1030 + '#1205': https://github.com/fedify-dev/fedify/pull/1205 +--- + - Fixed log entries emitted during an individual outbound activity delivery + or inbound activity processing carrying the enclosing HTTP request's or + queue worker's `traceId`/`spanId` instead of the delivery or processing + operation's own. Warning and error logs now match the `traceId`/`spanId` + of the `TraceActivityRecord` that operation produces (as exposed by + `@fedify/fedify/otel`), including when the operation runs inside a + background queue worker, which makes it possible to correlate a failure + log with the specific activity it belongs to. + [[#1030], [#1205] by Yujin Jung] diff --git a/changes.d/fedify/circuit-breaker-encoded-cas.md b/changes.d/fedify/circuit-breaker-encoded-cas.md deleted file mode 100644 index 3f3192ee2..000000000 --- a/changes.d/fedify/circuit-breaker-encoded-cas.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#1163': https://github.com/fedify-dev/fedify/issues/1163 - '#1167': https://github.com/fedify-dev/fedify/pull/1167 ---- - - Fixed outbound delivery circuit breaker transitions when a key–value - store compares encoded values. Existing half-open states can now recover - after switching to a CAS-capable store. [[#1163], [#1167]] diff --git a/changes.d/fedify/compatible-id-documents.md b/changes.d/fedify/compatible-id-documents.md deleted file mode 100644 index b379738e5..000000000 --- a/changes.d/fedify/compatible-id-documents.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -links: - '#1093': https://github.com/fedify-dev/fedify/issues/1093 - '#1105': https://github.com/fedify-dev/fedify/pull/1105 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed Fedify to treat documents whose IDs are [FEP-ef61] compatible - identifiers, such as - `https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor`, as the - portable objects they stand for, as FEP-ef61 requires. Some - implementations, e.g., tootik, identify their portable actors this way. - Previously, Fedify trusted such documents by the web origin that served - them, so any server could act as - `https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor` - without a proof by Alice's DID. [[#288], [#1093], [#1105]] - - - `verifyPortableObjectProof()` now verifies a document whose `@id` is - a compatible identifier against the DID in it, instead of reporting - it as `notPortableObject`. The proof is verified over the document - as is. - - - Inboxes now reject an activity of an actor whose ID is a compatible - identifier with `401 Unauthorized` unless it has a valid Object - Integrity Proof made by the actor's DID, as they already did for - actors with `ap:` IDs. Likewise, an activity whose own ID is an - `ap:` URI or a compatible identifier is rejected unless its proof is - made by the DID of that ID, and embedded maps with compatible - identifiers need their own proofs as embedded portable objects do. - The exception is a key embedded in the `publicKey` or - `assertionMethod` of a verified portable actor, such as a gateway - key, whose ID is the actor's compatible identifier plus a fragment; - the actor's proof covers it. - Activities of actors that publish compatible identifiers without - signing them, or whose DIDs use key types Fedify does not support, - are no longer accepted. - - - `verifyObject()` now accepts an Object Integrity Proof made by a DID - as authenticating an attribution or actor whose compatible identifier - has that DID, and never authenticates such an attribution by a key at - an ordinary URL. - - - HTTP Signature verification now resolves a gateway key whose actor - document has a compatible identifier as its ID the same way as for - actors with `ap:` IDs, and rejects the key if the document does not - have a valid proof by the actor's DID, instead of trusting the - gateway's web origin. A key at an ordinary URL that claims - a portable actor as its owner or controller is no longer that actor's - key, and `getKeyOwner()` and `doesActorOwnKey()` no longer resolve - portable actors by web origin. - - - `Context.sendActivity()` now applies the compound-proof key selection - to activities that embed objects with compatible identifiers, as it - does for embedded objects with `ap:` IDs, so that Fedify inboxes - accept what it sends. - - - Changed HTTP Signature verification to accept a gateway key of a portable - actor that the actor's signed document embeds only in its `publicKey`, - with the actor as its `owner`, when no `assertionMethod` entry has the - key ID. Some publishers, e.g., tootik, list their RSA keys only there. - A document that has more than one entry with the key ID in either - property is rejected. [[#288], [#1093], [#1105]] - - - Changed the gateway trust policy for unsecured portable collections in - `verifyPortableObject()` to accept collections and owners identified by - compatible identifiers. An owner with a compatible identifier is fetched - through the gateway that the identifier names first, unless the - `gateways` option is given. [[#288], [#1093], [#1105]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/compatible-id-publishing.md b/changes.d/fedify/compatible-id-publishing.md deleted file mode 100644 index 8dfb3b157..000000000 --- a/changes.d/fedify/compatible-id-publishing.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -links: - '#1106': https://github.com/fedify-dev/fedify/issues/1106 - '#1109': https://github.com/fedify-dev/fedify/pull/1109 - '#1155': https://github.com/fedify-dev/fedify/issues/1155 - '#1188': https://github.com/fedify-dev/fedify/pull/1188 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added support for publishing [FEP-ef61] portable actors and objects whose - IDs are compatible identifiers, such as - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, for - interoperability with software that cannot handle `ap:` URIs. Fedify now - treats such an actor or object as portable wherever it publishes one, as - it already did for those with `ap:` or `ap+ef61:` IDs. Build compatible - identifiers with `toCompatibleEf61Id()` and the first gateway of the - actor before signing the documents; portable IDs remain the recommended - form. [[#288], [#1106], [#1109]] - - - `Context.sendActivity()` now requires an activity of an actor whose - ID is a compatible identifier to have an `ap:` or `ap+ef61:` ID or - a compatible identifier of the actor's DID, and signs an unsigned one - only with the key whose ID is a DID URL for that DID, instead of - sending activities that FEP-ef61 receivers reject. A malformed - compatible actor or activity ID, e.g., one with `@gateway` location - hints, is rejected with a `TypeError`. - - - The FEP-ef61 gateway endpoint now serves an object whose ID is - a compatible identifier of the requested portable object, on any - gateway, instead of responding with `404 Not Found`. - - - Portable inboxes now accept deliveries on behalf of actors whose IDs - or inboxes are compatible identifiers, and forward each activity to - the other gateways at most once whether its ID is an `ap:` URI or - a compatible identifier. - - - WebFinger now takes the domain of such an actor's address from its - first gateway, and uses its ID as the `self` link. - - - Actor dispatchers no longer warn that such an actor's ID or collection - URIs do not match the URIs that `Context` builds, but warn if its ID - is a malformed compatible identifier or is not on its first gateway, - as FEP-ef61 requires. - - - Fedify now warns when a served object, or a sent activity and its - embedded objects, use compatible identifiers away from the owner's - first gateway when that gateway is known locally. The warning does - not change what is served or sent. [[#1155], [#1188]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/compound-portable-proofs.md b/changes.d/fedify/compound-portable-proofs.md deleted file mode 100644 index c14939a1e..000000000 --- a/changes.d/fedify/compound-portable-proofs.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -links: - '#1041': https://github.com/fedify-dev/fedify/pull/1041 - '#1094': https://github.com/fedify-dev/fedify/issues/1094 - '#1102': https://github.com/fedify-dev/fedify/pull/1102 - '#1133': https://github.com/fedify-dev/fedify/issues/1133 - '#1138': https://github.com/fedify-dev/fedify/pull/1138 - '#1151': https://github.com/fedify-dev/fedify/issues/1151 - '#1198': https://github.com/fedify-dev/fedify/pull/1198 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#938': https://github.com/fedify-dev/fedify/issues/938 ---- - - Security: Inbox processing now independently verifies each portable actor, - activity, and object in a compound JSON document against its own map-local - Object Integrity Proof before dispatch. A valid outer proof no longer - authenticates an unsigned or invalid nested portable object. - Portable documents with proof sets or which exceed the inbox compound - traversal limits are rejected as unsupported. The top-level Linked - Data Signature of an activity is not part of any proof input, so an - activity that carries both proofs and a Linked Data Signature is - accepted. An unsigned key embedded in the `publicKey` or - `assertionMethod` of a verified portable actor needs no proof of its own - if its ID is the actor's compatible identifier or `ap:` or `ap+ef61:` URI - plus a non-empty fragment, as with the keys that [FEP-ae97] clients make - for their actors, which Mitra serves under `ap:` URIs. - [[#288], [#938], [#1041], [#1094], [#1102], [#1133], [#1138]] - - The map-local profile is Fedify's interim interpretation, since neither - FEP-8b32 nor Verifiable Credential Data Integrity defines the boundaries - of embedded proofs yet, and may change in Fedify 3.0. The new - [*Portable objects*][portable objects] chapter of the manual describes - the [FEP-ef61] profile that Fedify supports, including this and the parts - of FEP-ef61 that Fedify does not implement. [[#288], [#1151], [#1198]] - -[FEP-ae97]: https://w3id.org/fep/ae97 -[portable objects]: https://fedify.dev/manual/portable -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/compound-proof-key-selection.md b/changes.d/fedify/compound-proof-key-selection.md deleted file mode 100644 index a3f48584e..000000000 --- a/changes.d/fedify/compound-proof-key-selection.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -links: - '#1041': https://github.com/fedify-dev/fedify/pull/1041 - '#1045': https://github.com/fedify-dev/fedify/issues/1045 - '#1073': https://github.com/fedify-dev/fedify/pull/1073 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed `Context.sendActivity()` so that an activity containing - [FEP-ef61] portable objects gets at most one Object Integrity Proof. - Previously Fedify signed every outgoing activity once for each Ed25519 - key, which produced a proof set that Fedify's own inbox rejects in a - compound portable document. [[#288], [#1041], [#1045], [#1073]] - - - An activity that already carries a proof is sent as is. - - With several Ed25519 keys, a portable activity is signed only by the - key whose ID is a DID URL for the activity's DID. - - If no single key qualifies, or a non-portable activity that embeds - portable objects has several Ed25519 keys, `sendActivity()` rejects - with a `TypeError` before anything is delivered or queued. Pass - explicit sender keys with exactly one Ed25519 key, or sign the - activity with `signObject()` beforehand. - - An activity with portable objects in which any map carries a proof - set is rejected the same way. - - Activities without portable objects are signed as before. - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/document-loader-timeout.md b/changes.d/fedify/document-loader-timeout.md deleted file mode 100644 index fb8aaffc7..000000000 --- a/changes.d/fedify/document-loader-timeout.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -links: - '#1131': https://github.com/fedify-dev/fedify/issues/1131 - '#1169': https://github.com/fedify-dev/fedify/pull/1169 ---- - - Changed the built-in document loader, context loader, and authenticated - document loader to time out each call after 10 seconds by default. - Previously, a remote server that responded slowly or never could hold - a request for as long as the runtime and the network allowed, e.g., - an incoming activity whose signature key Fedify fetched, and the sender - picks such key URLs. The time limit covers the whole call, including - every redirect and alternate document it follows, double-knocking - retries, and reading the response body. A call that times out throws - a `FetchError` without a response, whose `cause` is a `DOMException` - named `"TimeoutError"`, so a key fetch that times out is reported as - a `keyFetchError` by `verifyRequestDetailed()` and is cached like other - failures to fetch a key. Custom document loaders are not affected. - [[#1131], [#1169]] - - - Added `FederationOptions.documentLoaderTimeout` option to change - the time limit, or to turn it off with `null`. - - - Added the `timeout` option to `getAuthenticatedDocumentLoader()`. - - - Changed the built-in document loaders to read the body of an error - response before they throw a `FetchError`, at most 1 MiB, so that - the time limit also bounds reading it. `FetchError.response` keeps - the body byte for byte, or only the status and headers if the body is - larger than that. [[#1131], [#1169]] diff --git a/changes.d/fedify/hashlink-media-serving.md b/changes.d/fedify/hashlink-media-serving.md deleted file mode 100644 index 3cb24193c..000000000 --- a/changes.d/fedify/hashlink-media-serving.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -links: - '#1080': https://github.com/fedify-dev/fedify/pull/1080 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#838': https://github.com/fedify-dev/fedify/issues/838 ---- - - Added serving of resources addressed by [FEP-ef61] hashlinks, such as - media attached to portable objects, through the gateway endpoint, e.g., - `GET /.well-known/apgateway/hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n`. - Register a dispatcher with the new - `Federatable.setHashlinkMediaDispatcher()` method; it receives the parsed - hashlink and its SHA-256 digest as a `HashlinkMediaRequest` object and - returns a `Response`, which Fedify sends as is so that it can be streamed, - or `null` for `404 Not Found`. Fedify responds with `400 Bad Request` to - malformed hashlinks without calling the dispatcher, and does not verify the - response body, so the dispatcher must serve only the resource whose bytes - hash to the requested digest. Applications that do not register the - dispatcher are unaffected. [[#288], [#838]] - - - Added `hashlink_media` to the values of the `fedify.endpoint` metric - attribute, used for requests to the hashlink media endpoint. - [[#288], [#838], [#1080]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/imported-unreleased.md b/changes.d/fedify/imported-unreleased.md deleted file mode 100644 index e808e467e..000000000 --- a/changes.d/fedify/imported-unreleased.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -links: - '#206': https://github.com/fedify-dev/fedify/issues/206 - '#754': https://github.com/fedify-dev/fedify/issues/754 - '#797': https://github.com/fedify-dev/fedify/issues/797 - '#798': https://github.com/fedify-dev/fedify/issues/798 - '#799': https://github.com/fedify-dev/fedify/issues/799 - '#803': https://github.com/fedify-dev/fedify/pull/803 - '#806': https://github.com/fedify-dev/fedify/pull/806 - '#812': https://github.com/fedify-dev/fedify/pull/812 - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#827': https://github.com/fedify-dev/fedify/issues/827 - '#829': https://github.com/fedify-dev/fedify/issues/829 - '#832': https://github.com/fedify-dev/fedify/issues/832 - '#915': https://github.com/fedify-dev/fedify/pull/915 - '#923': https://github.com/fedify-dev/fedify/pull/923 - '#925': https://github.com/fedify-dev/fedify/pull/925 - '#926': https://github.com/fedify-dev/fedify/pull/926 - '#927': https://github.com/fedify-dev/fedify/pull/927 - '#930': https://github.com/fedify-dev/fedify/issues/930 - '#934': https://github.com/fedify-dev/fedify/pull/934 - '#968': https://github.com/fedify-dev/fedify/pull/968 ---- - - Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received - proof option except `proofValue`, including `expires`, `domain`, - `challenge`, `nonce`, and extension options. It now rejects expired or - malformed proof options, and callers can provide expected `domain` and - `challenge` values through `VerifyProofOptions` to prevent cross-domain or - replay use. [[#968]] - - - Added `verifyPortableObjectProof()` to enforce the [FEP-ef61] proof policy - for portable actors, activities, objects, and signed collections. Its - detailed result distinguishes documents outside the policy, unsecured - collections, missing or invalid [FEP-8b32] proofs, unsupported verification - methods, DID authority mismatches, and successful verification. - [[#832], [#968]] - - - Updated `verifyObject()` so [FEP-8b32] proofs signed by `did:key` - verification methods can authenticate portable objects whose owner is an - `ap:` or `ap+ef61:` URI with the same [FEP-fe34] cryptographic origin. - [[#829], [#926]] - - - Added local `did:key` verification method resolution for - [FEP-8b32] Object Integrity Proofs. `verifyProof()` can now verify - Ed25519 `eddsa-jcs-2022` proofs whose `verificationMethod` is a - `did:key:z...#z...` DID URL without fetching the verification method - as a remote JSON-LD document, which is required for [FEP-ef61] - portable objects. - [[#827], [#915]] - - - Added support for the [ActivityPub Media Upload extension] so that servers - can accept client-to-server media uploads: - [[#754], [#927]] - - - `Federation` and `FederationBuilder` gained a `setMediaUploader()` - method (through the new `MediaUploaderSetters` interface) that registers - a `multipart/form-data` upload endpoint. Its callback finalizes the - uploaded `file` alongside the posted `object` shell and returns either - the created object (`201 Created`) or the `URL` at which it will become - available once processing finishes (`202 Accepted`). - - `Context` gained a `getMediaUploaderUri()` method for building the - endpoint URI, which actor dispatchers advertise under the new - `Endpoints.uploadMedia` property. - - A new metric endpoint category, `media_upload`, classifies these - requests in the `fedify.endpoint` attribute. - - Fedify logs a runtime warning when a callback's returned URI does not - point at a registered object dispatcher route, when a registered - media uploader is not advertised under `endpoints.uploadMedia`, or - when a media uploader is registered without an `authorize()` hook (so - the endpoint would accept uploads from anyone). - - - Added a custom background task API that generalizes Fedify's - enqueue-and-process-later pattern to arbitrary application-defined jobs: - - - `Federation` and `FederationBuilder` gained a `defineTask()` method - through the new `TaskRegistry` interface, which `Federatable` now - extends. - - `Context` gained `enqueueTask()` and `enqueueTaskMany()` methods, - with `delay` and `orderingKey` options - (new `TaskEnqueueOptions` interface). - - Every task requires a [Standard Schema] - (`schema` option) from which the payload type is inferred; payloads - are validated at enqueue time (fail fast) and again at dequeue time - (protection against schema drift across deployments). - - Payloads are serialized by Fedify with devalue, so `Date`, `Map`, - `Set`, `URL`, `bigint`, circular references, and Activity Vocabulary - objects round-trip faithfully across every message queue backend. - - Failed handlers are retried with exponential backoff by default; - tasks support per-task `retryPolicy` and `onError` options, the new - `FederationOptions.taskRetryPolicy` sets the federation-wide default, - and queues with `nativeRetrial` delegate retries to the backend. - - Tasks can be isolated from activity delivery through the new - `FederationQueueOptions.task` slot or a per-task `queue` option; - without them, tasks fall back to the outbox queue unless the new - `FederationOptions.taskQueueResolution` option is set to `"strict"`. - `Federation.startQueue()` now accepts `queue: "task"` to run - a task-only worker. - - Tasks can request at-most-once enqueue with a `deduplicationKey` - (new `TaskEnqueueOptions.deduplicationKey`). A queue declaring the new - `MessageQueue.nativeDeduplication` capability owns the check and - receives the key through the new - `MessageQueueEnqueueOptions.deduplicationKey`; otherwise Fedify - performs a best-effort key–value guard through the optional - `KvStore.cas` primitive, under a new `taskDeduplication` key prefix. - The marker TTL and the no-`cas` fallback are tunable with the new - `FederationOptions.taskDeduplicationTtl` and - `FederationOptions.taskDeduplicationFallback` options. - [[#206], [#797], [#798], [#799], [#803], [#806], [#812], [#923] by ChanHaeng Lee\] - - - Added `MessageQueue.atomicEnqueueMany` for queues that implement - `enqueueMany()` with separate sends. Fedify still uses their batch path - normally, but rejects a multi-message batch governed by one - `deduplicationKey` before a partial send can undermine deduplication. - [[#930], [#934]] - - - Fixed CommonJS distribution files that use Temporal so they no longer - require `@js-temporal/polyfill` at runtime. The CommonJS build now - bundles `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] - -[FEP-ef61]: https://w3id.org/fep/ef61 -[FEP-8b32]: https://w3id.org/fep/8b32 -[FEP-fe34]: https://w3id.org/fep/fe34 -[ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload -[Standard Schema]: https://standardschema.dev/ diff --git a/changes.d/fedify/inbox-authentication.md b/changes.d/fedify/inbox-authentication.md new file mode 100644 index 000000000..9075d3ea8 --- /dev/null +++ b/changes.d/fedify/inbox-authentication.md @@ -0,0 +1,50 @@ +--- +links: + '#1289': https://github.com/fedify-dev/fedify/issues/1289 + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - Added `InboxContext.authentication`, which tells inbox listeners who Fedify + authenticated for an incoming activity independently of its declared + `actor`: the signers of the HTTP request, the signers of the activity's + Linked Data Signature or Object Integrity Proofs, and the activity as Fedify + interpreted it while authenticating it. A valid signature does not + authorize an activity by itself; use the new `isActorAuthenticated()` + function to check whether a specific actor was authenticated, e.g., the + owner of an object the activity acts on. The new `InboxPrincipal` and + `InboxAuthenticationEvidence` types describe the evidence. + [[#1290], [#1299]] + + - Added the `allowActorless` option to `InboxListenerSetters.on()`, so that + activities declaring no `actor` at all, such as the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends, can reach a listener. Such an + activity is dispatched only if Fedify authenticated a principal for it and + its `id` has the same origin as that principal; the listener must authorize + it with `InboxContext.authentication`. Without the option, these + activities are still rejected with `400 Bad Request`. An activity whose + `actor` cannot be read, such as an anonymous actor, is never treated as + having no actor. Rejected deliveries are reported with the new + `noAuthenticatedPrincipal` reason in `onRequestFinished` reports. + [[#1289], [#1290], [#1299]] + + - Changed the inbox to interpret each incoming activity with one set of + JSON-LD context documents throughout signature verification and dispatch, + and to keep that interpretation, along with the authentication evidence, + in queued inbox messages. Queue workers and their retries no longer load + remote JSON-LD contexts again for such messages, so a remote context that + changes in the meantime cannot make a listener see an activity other than + the one that was authenticated. An activity using a context that cannot + be kept this way, such as one a custom context loader marks as `static`, + is processed right away instead of being queued. Messages queued by + older versions are processed as before. [[#1290], [#1299]] + + - Added the `MessageQueue.maxMessageSize` property. When an incoming + activity would make an inbox message larger than the queue can hold, it is + now processed right away instead of being enqueued. [[#1290], [#1299]] + + - Changed inbox queue workers to deduplicate activities with the configured + `idempotency` strategy, as direct processing already did, instead of always + deduplicating them per origin. Activities recorded by workers of older + versions are still recognized. [[#1290], [#1299]] + +[FEP-7aa9]: https://w3id.org/fep/7aa9 diff --git a/changes.d/fedify/kv-cache-ttl.md b/changes.d/fedify/kv-cache-ttl.md deleted file mode 100644 index f3caf4e8b..000000000 --- a/changes.d/fedify/kv-cache-ttl.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -links: - '#1017': https://github.com/fedify-dev/fedify/issues/1017 - '#1027': https://github.com/fedify-dev/fedify/pull/1027 ---- - - Changed cached actor public keys and remembered per-origin HTTP Message - Signatures specs to expire, so a `KvStore` that never sees an explicit - clear no longer accumulates entries for actors and origins that have - stopped federating. Keys expire after 30 days and specs after 90 days - by default, and both windows are configurable through the new - `FederationOptions.publicKeyTtl` and - `FederationOptions.httpMessageSignaturesSpecTtl` options. - [[#1017], [#1027] by Heewon Chae] - - - Shortening a window trades storage for remote requests: an expired - key has to be refetched before the next signature verification, and - an expired spec has to be relearned by double-knocking on the next - delivery. Refetching fails while the peer is unavailable, so a very - short window makes verification depend on the peer being reachable. - - Entries written by earlier versions of Fedify have no expiry and are - left as they are; they gain one the next time they are written. See - the new *Clearing legacy cache entries* section of the - [key–value store guide] to clear them proactively instead of waiting. - -[key–value store guide]: https://fedify.dev/manual/kv diff --git a/changes.d/fedify/object-tombstones.md b/changes.d/fedify/object-tombstones.md deleted file mode 100644 index 5132b747b..000000000 --- a/changes.d/fedify/object-tombstones.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -links: - '#1112': https://github.com/fedify-dev/fedify/issues/1112 - '#1117': https://github.com/fedify-dev/fedify/pull/1117 ---- - - Allowed object dispatchers to return `Tombstone` for deleted objects, as - actor dispatchers already can. Fedify now serves such object URIs with - `410 Gone` and the serialized tombstone body, as ActivityPub recommends, - after applying the authorization predicate as for other objects, so that - applications no longer need a separate route in front of Fedify for - deleted posts. Fedify logs a warning if the tombstone's `id` does not - match `Context.getObjectUri()`. [[#1112], [#1117]] - - - Changed the return type of `ObjectDispatcher` from - `TObject | null` to `TObject | Tombstone | null`. - - Added a `RequestContext.getObject()` overload that takes - a `GetObjectOptions` object. By default, `getObject()` still returns - `null` for a tombstone unless the tombstone is an instance of - the requested class, e.g., `Object` or `Tombstone`. Pass - `{ tombstone: "passthrough" }` to receive tombstones. - - Added the `GetObjectOptions` interface. - - Changed object dispatchers registered for `Tombstone` or `Object` - that return tombstones to be served with `410 Gone` instead of - `200 OK`. diff --git a/changes.d/fedify/outbox-portable-actor-references.md b/changes.d/fedify/outbox-portable-actor-references.md deleted file mode 100644 index 9909a13ad..000000000 --- a/changes.d/fedify/outbox-portable-actor-references.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -links: - '#1159': https://github.com/fedify-dev/fedify/issues/1159 - '#1189': https://github.com/fedify-dev/fedify/pull/1189 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Fixed outbox listeners rejecting a posted activity with - `400 Bad Request` when its `actor` referred to an [FEP-ef61] portable - outbox owner in another form than the owner's ID, e.g., with `@gateway` - location hints, another URI scheme or DID encoding, or as a compatible - identifier on another gateway. Portable actor IDs are now compared by - their canonical forms, while other actor IDs still have to match exactly. - [[#288], [#1159], [#1189]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-actor-gateways.md b/changes.d/fedify/portable-actor-gateways.md deleted file mode 100644 index 476e13ef3..000000000 --- a/changes.d/fedify/portable-actor-gateways.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -links: - '#1148': https://github.com/fedify-dev/fedify/issues/1148 - '#1178': https://github.com/fedify-dev/fedify/pull/1178 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added validation of the `gateways` of [FEP-ef61] portable actors, which - FEP-ef61 requires to be a non-empty list of HTTP(S) URIs with an empty - path, query, and fragment. `verifyPortableObjectProof()` and - `verifyPortableObject()` now reject a portable actor whose `gateways` is - missing or empty, or has any other item, with the new `invalidGateways` - reason, even if its proofs are valid, so such an actor is no longer looked - up, accepted in an inbox, or served through the gateway endpoint. If an - actor dispatcher returns a portable actor with such `gateways`, Fedify - logs a warning. An incoming activity that embeds an actor with a gateway - that has a path, query, or fragment is now rejected with - `400 Bad Request` as malformed, instead of failing with an error. - [[#288], [#1148], [#1178]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-actor-uri.md b/changes.d/fedify/portable-actor-uri.md deleted file mode 100644 index a5e7648fc..000000000 --- a/changes.d/fedify/portable-actor-uri.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -links: - '#1114': https://github.com/fedify-dev/fedify/pull/1114 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#841': https://github.com/fedify-dev/fedify/issues/841 ---- - - Added the `Context.getPortableActorUri()` method, which builds the - [FEP-ef61] portable ID of an actor from its actor dispatcher's path and - a DID, e.g., `ap+ef61://did:key:z6Mk.../users/alice`. Like - `Context.getPortableObjectUri()`, the DID can be omitted while handling - a gateway request, in which case the DID in the request path is used. - Custom implementations of the `Context` interface need to implement the - new method. [[#288], [#841], [#1114]] - - - The actor dispatcher now serves portable actors through the FEP-ef61 - gateway endpoint, e.g., - `GET /.well-known/apgateway/did:key:z6Mk.../users/alice`, the same way - object dispatchers serve portable objects, so a portable actor whose ID - `Context.getPortableActorUri()` builds can be fetched at its compatible - identifier without registering an extra object dispatcher. The actor is - served only if its ID canonically equals the requested portable ID and it - has an Object Integrity Proof made with a key of the DID, and - `RequestContext.portableRequest` is set for such requests. Applications - that do not have portable actors are unaffected, except that their actor - dispatchers may be called for such requests. [[#288], [#841], [#1114]] - - - `Context.getPortableActorUri()`, `Context.getPortableObjectUri()`, and - `Context.getPortableInboxUri()` now throw a `TypeError` if the DID is - a `did:key` DID that is not encoded in base58-btc, as FEP-ef61 requires, - so that the same key does not yield two different portable IDs. Use - `exportDidKey()` from `@fedify/vocab-runtime` to make such DIDs. - Likewise, the gateway endpoint responds with `400 Bad Request` to - requests whose path has such a DID. [[#288], [#841], [#1114]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-chain-verifiers.md b/changes.d/fedify/portable-chain-verifiers.md deleted file mode 100644 index 2bb8831f7..000000000 --- a/changes.d/fedify/portable-chain-verifiers.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -links: - '#1107': https://github.com/fedify-dev/fedify/issues/1107 - '#1120': https://github.com/fedify-dev/fedify/pull/1120 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the `Context.verifyPortableObject` property, the [FEP-ef61] - portable object policy of the context: `verifyPortableObject()` with the - context's document loader and context loader. Since it has the same name - as the `verifyPortableObject` option of property accessors, passing - a context as their options, e.g., `await create.getObject(ctx)`, now - verifies portable objects that they dereference. Previously, - `create.getObject(ctx)` on an activity whose ID is a compatible - identifier, such as one from tootik, fetched the object it refers to as - an ordinary HTTP(S) URL and trusted it because of its origin. Note that - this also applies to objects with ordinary HTTP(S) IDs: their references - to compatible identifiers are now verified as portable objects when you - pass a context. [[#288], [#1107], [#1120]] - - - Inboxes now parse received activities with `Context.verifyPortableObject` - as their default verifier, so accessors called without options in inbox - listeners, e.g., `await create.getObject()`, verify portable objects - too, including for queued activities and for activities authenticated - by Object Integrity Proofs. The activities passed to the - `onOutboxError` callback have the default as well. - [[#288], [#1107], [#1120]] - - - Changed `Context.lookupObject()` to use `Context.verifyPortableObject` - instead of `verifyPortableObjectProof()` as the default - `verifyPortableObject` option, so that it also accepts an unsecured - portable collection served by a gateway that its owner lists, as - accessors do. Pass `verifyPortableObject: verifyPortableObjectProof` to - accept only objects with proofs. The returned object uses the policy by - default for its property accessors. `Context.traverseCollection()` also - uses `Context.verifyPortableObject` by default. [[#288], [#1107], [#1120]] - - - Added the `verifyPortableObject` option to `VerifyObjectOptions`. It is - not used to verify the given object; the returned object uses it by - default for its property accessors. [[#288], [#1107], [#1120]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-collection-trust.md b/changes.d/fedify/portable-collection-trust.md deleted file mode 100644 index 95cbbcd75..000000000 --- a/changes.d/fedify/portable-collection-trust.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -links: - '#1084': https://github.com/fedify-dev/fedify/pull/1084 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#836': https://github.com/fedify-dev/fedify/issues/836 ---- - - Added `verifyPortableObject()`, which applies the [FEP-ef61] trust policy - to portable objects fetched through gateways. It verifies Object - Integrity Proofs like `verifyPortableObjectProof()`, and also accepts - a portable collection or collection page without a proof if it was - served by a gateway listed in the `gateways` of the actor that owns it. - The owner must be the actor whose `inbox`, `outbox`, `followers`, - `following`, or `liked` it is, as confirmed by the owner's signed actor - document. Pass it as the `verifyPortableObject` option of property - accessors and `traverseCollection()` instead of - `verifyPortableObjectProof()` to read portable actors' collections, such - as their outboxes. The new `VerifyPortableObjectOptions`, - `VerifyPortableObjectResult`, and `VerifyPortableObjectFailureReason` - types describe it. [[#288], [#836], [#1084]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-collections.md b/changes.d/fedify/portable-collections.md deleted file mode 100644 index 285e3fbb3..000000000 --- a/changes.d/fedify/portable-collections.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -links: - '#1111': https://github.com/fedify-dev/fedify/issues/1111 - '#1142': https://github.com/fedify-dev/fedify/pull/1142 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added serving of [FEP-ef61] portable collections through the gateway - endpoint, e.g., - `GET /.well-known/apgateway/did:key:z6Mk.../users/alice/outbox`, by the - existing collection dispatchers, including the inbox collection, so that a - portable actor's outbox and other collections are no longer tied to a - single server. Fedify serves such a collection only if the actor whose - identifier is in its path is a portable actor under the requested DID whose - corresponding property, e.g., `outbox`, refers to the collection; otherwise - it responds with `404 Not Found`. The collection and its pages keep the - actor's form of the ID, a portable ID or a compatible identifier, identify - pages with the `cursor` query parameter, and have `attributedTo` set to the - actor. The collection's `authorize()` predicate is applied before anything - is dispatched. The collection itself is served without an Object Integrity - Proof, as FEP-ef61 allows, but it is refused with - `500 Internal Server Error` if it embeds a portable actor, activity, or - object without a valid proof made with a key of the DID in its ID. - Applications without portable actors are unaffected, except that their - actor dispatchers may be called for such requests. - [[#288], [#1111], [#1142]] - - - Added the `Context.getPortableOutboxUri()`, - `Context.getPortableFollowingUri()`, `Context.getPortableFollowersUri()`, - `Context.getPortableLikedUri()`, `Context.getPortableFeaturedUri()`, - `Context.getPortableFeaturedTagsUri()`, and - `Context.getPortableCollectionUri()` methods, which build the portable IDs - of an actor's collections and of custom collections from the same paths - as their non-portable counterparts and a DID. On a `RequestContext`, the - DID defaults to the one in the gateway request. Custom implementations of - the `Context` interface need to implement the new methods. - [[#288], [#1111], [#1142]] - - - Added the `CustomCollectionCallbackSetters.mapPortableOwner()` method and - the `PortableCollectionOwnerMapper` type. A custom collection is served - through the FEP-ef61 gateway endpoint only if this callback maps it to - a portable actor under the requested DID. [[#288], [#1111], [#1142]] - - - Changed the actor dispatcher to warn when a portable actor's collection - property is a portable ID or a compatible identifier that does not match - the one that the corresponding `Context.getPortable*Uri()` method builds, - as the gateway endpoint would not serve the collection. - [[#288], [#1111], [#1142]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-delivery.md b/changes.d/fedify/portable-delivery.md deleted file mode 100644 index 2e62c9205..000000000 --- a/changes.d/fedify/portable-delivery.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -links: - '#1147': https://github.com/fedify-dev/fedify/issues/1147 - '#1180': https://github.com/fedify-dev/fedify/pull/1180 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added delivery to [FEP-ef61] portable inboxes, i.e., `ap:` and `ap+ef61:` - URIs, to `Context.sendActivity()` and `InboxContext.forwardActivity()`. - They deliver an activity to the compatible identifier of such an inbox - on one of the recipient's gateways, e.g., - `https://gateway.example/.well-known/apgateway/did:key:z6Mk.../actor/inbox`. - The gateways are taken from the recipient's `gateways`, or, if it has no - valid gateway, from the `@gateway` location hints of the inbox URI, and - at most five of them are tried one after another until one accepts the - activity. Previously, such a delivery failed with - `UrlError: Unsupported protocol: ap+ef61:`. A recipient with no gateway - to deliver through is skipped with a warning. [[#288], [#1147], [#1180]] - - - A gateway that fails with a permanent failure status, such as - `404 Not Found`, is not tried again for the activity. If no gateway - accepts the activity, the whole round of gateways counts as one - attempt of the retry policy. - - - The `preferSharedInbox` option is ignored for recipients with portable - inboxes, the `excludeBaseUris` option is compared with the origins of - their gateways, and deliveries to a portable inbox are ordered by the - actor's DID when the `orderingKey` option is given. - - - Queue workers of older Fedify versions deliver queued activities to - portable inboxes only through the first gateway, so upgrade them - before the servers that enqueue deliveries. - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-dot-segments.md b/changes.d/fedify/portable-dot-segments.md deleted file mode 100644 index 7886b2adc..000000000 --- a/changes.d/fedify/portable-dot-segments.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#1154': https://github.com/fedify-dev/fedify/issues/1154 - '#1186': https://github.com/fedify-dev/fedify/pull/1186 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Fixed portable actor and object URI helpers so a dispatcher identifier - containing a `.` or `..` path segment raises a `TypeError` instead of - silently producing another object's ID. Use identifiers without such - segments. [[#288], [#1154], [#1186]] diff --git a/changes.d/fedify/portable-gateway-keys.md b/changes.d/fedify/portable-gateway-keys.md deleted file mode 100644 index 5851d59c6..000000000 --- a/changes.d/fedify/portable-gateway-keys.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -links: - '#1095': https://github.com/fedify-dev/fedify/issues/1095 - '#1096': https://github.com/fedify-dev/fedify/issues/1096 - '#1099': https://github.com/fedify-dev/fedify/pull/1099 - '#1119': https://github.com/fedify-dev/fedify/pull/1119 - '#1123': https://github.com/fedify-dev/fedify/issues/1123 - '#1132': https://github.com/fedify-dev/fedify/issues/1132 - '#1134': https://github.com/fedify-dev/fedify/pull/1134 - '#1164': https://github.com/fedify-dev/fedify/pull/1164 - '#1165': https://github.com/fedify-dev/fedify/pull/1165 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#840': https://github.com/fedify-dev/fedify/issues/840 ---- - - Added support for HTTP Signature keys that gateways use on behalf of - [FEP-ef61] portable actors, which Fedify calls *gateway keys*. Register - the new `ActorCallbackSetters.mapPortableActorId()` callback to tell - Fedify which actors are portable, and `Context.getActorKeyPairs()` then - identifies their dispatched key pairs by the actor's compatible identifier - on this server, e.g., - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actors/alice#main-key`, - with the portable actor as their owner, and with the same IDs for their - `CryptographicKey` and `Multikey` forms so that they can be listed in the - actor's `assertionMethods` as FEP-ef61 requires. Requests made on behalf - of such actors, including activity deliveries and signed fetches, are - signed with these keys. The new `PortableActorIdMapper` type describes the - callback. [[#288], [#840], [#1099]] - - - Changed HTTP Signature verification to accept a gateway key of a portable - actor if the actor's document, fetched from the key ID, has a valid Object - Integrity Proof made by the actor's DID, embeds the key in its - `assertionMethod`, and lists the gateway in its `gateways`. Such a key is - owned by the portable actor, so `RequestContext.getSignedKeyOwner()`, - `getKeyOwner()`, and `doesActorOwnKey()` return or match the portable - actor. Given the very key that HTTP Signature verification returned, - they take the actor from the document it verified rather than fetching - and verifying the document again. The document may list the key under - the key ID itself or under the `ap:` URI with the same canonical ID, as - Mitra does, but not under both, nor under a compatible identifier on - another gateway. Previously, no gateway key of a portable actor could be - verified. [[#288], [#840], [#1096], [#1099], [#1123], [#1134], [#1164]] - - - Changed HTTP Signature verification to accept a key of a portable actor - itself whose ID is an `ap:` or `ap+ef61:` URI, e.g., - `ap://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fexample.com#main-key`. - Fedify fetches the actor's document from the gateways in the key ID's - `@gateway` location hints, or, without them, asks the document loader for - the `ap:` URI itself, and accepts the key if the document has a valid - Object Integrity Proof made by the actor's DID, embeds the key under - the `ap:` URI, and has a valid gateway. Since the signer chooses the - hints, only the first three are followed, and all the gateways share - a timeout of ten seconds; a lookup that runs out of time is reported as - a `keyFetchError` without an HTTP status. Such a key is used only for - HTTP Signatures, and is owned by the portable actor like a gateway key. - Keys at `ap:` URIs are never used to make Object Integrity Proofs or - Linked Data Signatures either. [[#288], [#1096], [#1132], [#1134], [#1165]] - - - Changed inboxes to reject activities of portable actors that do not have - a valid Object Integrity Proof made by the actor's DID with - `401 Unauthorized`, even if the request has a valid HTTP Signature or the - activity has a valid Linked Data Signature. [[#288], [#840], [#1099]] - - - Changed `Context.sendActivity()` so that keys whose IDs are FEP-ef61 - compatible identifiers, such as gateway keys, never make Object Integrity - Proofs or Linked Data Signatures; they only sign HTTP requests. Also, - activities of portable actors never get Linked Data Signatures, and have - to have portable IDs of the actors' DIDs, and an unsigned portable - activity is signed only by a key whose ID is a DID URL for its DID, even - if it is the only Ed25519 key. In these cases `sendActivity()` rejects - with a `TypeError` before anything is delivered or queued. Sign - portable activities with `signObject()` beforehand, or pass the DID's key - as an explicit sender key. [[#288], [#840], [#1099]] - - - Changed key lookups so that keys whose IDs are FEP-ef61 compatible - identifiers are cached apart for each purpose, as whether such a key is - valid depends on what it is used for: a gateway key cached for an HTTP - Signature is never used for an Object Integrity Proof or a Linked Data - Signature, and a key rejected for one of them is not rejected for - the others. A failure to fetch such a key, e.g., a network error or - `404 Not Found`, fails every purpose alike and is cached like that of any - other key. A gateway key is cached for an hour at most, and never - beyond the expiration of the proof on the actor's document, since - the actor can drop the gateway from its document at any time. - `fetchKey()` and `fetchKeyDetailed()` do not resolve gateway keys of - portable actors. - [[#288], [#840], [#1095], [#1099], [#1119]] - - - Only the key cache that Fedify's inbox uses caches keys at compatible - identifiers apart for each purpose. A custom `KeyCache` passed to - `verifyRequest()`, `fetchKey()`, and the like caches only the - failures to fetch them. - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-inbox.md b/changes.d/fedify/portable-inbox.md deleted file mode 100644 index 173f8801d..000000000 --- a/changes.d/fedify/portable-inbox.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -links: - '#1092': https://github.com/fedify-dev/fedify/pull/1092 - '#1100': https://github.com/fedify-dev/fedify/issues/1100 - '#1101': https://github.com/fedify-dev/fedify/issues/1101 - '#1104': https://github.com/fedify-dev/fedify/pull/1104 - '#1110': https://github.com/fedify-dev/fedify/pull/1110 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#839': https://github.com/fedify-dev/fedify/issues/839 ---- - - Added delivery to [FEP-ef61] portable inboxes through the gateway endpoint, - e.g., `POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox`. - Existing inbox listeners handle such a delivery with the path after the - DID, so the inbox path `/users/{identifier}/inbox` also receives - deliveries to the portable inbox - `ap+ef61://did:key:z6Mk.../users/alice/inbox`. Fedify accepts a delivery - only if the actor dispatcher returns, for the identifier, a portable actor - with the same DID whose `inbox` is the requested portable inbox and whose - `gateways` include this server; otherwise, it responds with - `404 Not Found`. Applications without portable actors are unaffected. - [[#288], [#839], [#1092]] - - - Fedify now forwards an activity delivered to a portable inbox to the same - inbox on the actor's other gateways, as FEP-ef61 recommends, if the - activity is authenticated by its own Object Integrity Proof or Linked Data - Signature. Each activity is forwarded to each gateway at most once, which - requires a `KvStore` that supports `cas()`; with other stores, Fedify logs - a warning and does not forward. Forwarded requests are signed with HTTP - Signatures by this server's gateway key for the actor if - `mapPortableActorId()` maps the recipient to the same portable actor and - the key pairs dispatcher returns an RSA key pair for it, and are sent - unsigned otherwise. An activity is not forwarded back to the gateway that - forwarded it if the delivery is signed by that gateway with its gateway - key for the actor, which costs fetching the actor document from that - gateway. Configure the `origin` option on gateways so that Fedify can - tell which gateway it is regardless of the `Host` header. - [[#288], [#839], [#1092], [#1100], [#1101], [#1104], [#1110]] - - - Added the `Context.getPortableInboxUri()` method, which builds the portable - inbox ID of an actor from the inbox path and a DID. Custom - implementations of the `Context` interface need to implement the new - method. [[#288], [#839], [#1092]] - - - Added the `FederationOptions.portableInboxForwarding` option, whose - `maxTargets`, `ttl`, and `deadline` properties change how many gateways - a delivery is forwarded to (10 by default; `0` turns off forwarding), how - long forwarded activities are remembered (30 days by default), and how - long a delivery waits for forwarding without an outbox queue, and for - identifying the gateway that forwarded it (10 seconds by default). - [[#288], [#839], [#1092], [#1101], [#1110]] - - - Added the `FederationKvPrefixes.portableInboxForwarding` option, the key - prefix for remembering forwarded activities, which defaults to - `["_fedify", "portableInboxForwarding"]`. [[#288], [#839], [#1092]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-object-serving.md b/changes.d/fedify/portable-object-serving.md deleted file mode 100644 index 04490c38c..000000000 --- a/changes.d/fedify/portable-object-serving.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -links: - '#1076': https://github.com/fedify-dev/fedify/pull/1076 - '#1113': https://github.com/fedify-dev/fedify/issues/1113 - '#1124': https://github.com/fedify-dev/fedify/pull/1124 - '#1153': https://github.com/fedify-dev/fedify/issues/1153 - '#1183': https://github.com/fedify-dev/fedify/pull/1183 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#835': https://github.com/fedify-dev/fedify/issues/835 ---- - - Added serving of [FEP-ef61] portable objects through the gateway endpoint, - e.g., `GET /.well-known/apgateway/did:key:z6Mk.../notes/123`. Existing - object dispatchers serve such a request with the path after the DID, so - the object dispatcher registered for `/notes/{id}` serves the portable - object `ap+ef61://did:key:z6Mk.../notes/123`. Fedify responds with - `404 Not Found` unless the dispatcher returns an object with that ID, and - refuses to serve a portable actor, activity, or object that does not have - an Object Integrity Proof made with a key of the DID. As FEP-ef61 - forbids gateways to serve a non-public object to anyone but its intended - audience, and a gateway may store objects that it did not create, Fedify - also responds with `404 Not Found` unless the object is publicly - addressed, i.e., its `to`, `cc`, `bto`, `bcc`, or `audience` has - the public collection, or is an actor. To serve non-public portable - objects to their audience, set an authorization predicate on the object - dispatcher, e.g., one that checks the request with the new - `RequestContext.isSignedByAudience()` method; the predicate then decides - who may retrieve the dispatcher's portable objects, and Fedify responds to - the requests that it allows with `Cache-Control: private`. Applications - that do not serve portable objects are unaffected, except that their - object dispatchers may be called for such requests. - [[#288], [#835], [#1076], [#1153], [#1183]] - - - Added the `RequestContext.isSignedByAudience()` method, which checks - whether the request is signed by an actor in the audience of an object, - comparing [FEP-ef61] portable IDs canonically, and the - `IsSignedByAudienceOptions` interface. Its `isMember` option checks - the members of collections such as followers, which Fedify cannot tell - by itself. Custom implementations of the `RequestContext` interface need - to implement the new method. [[#288], [#1153], [#1183]] - - - Added the `Context.getPortableObjectUri()` method, which builds the - portable ID of an object from its object dispatcher's path and a DID, - and the `RequestContext.portableRequest` property, which tells an object - dispatcher the DID and ID of the requested portable object. Custom - implementations of the `Context` interface need to implement the new - method. [[#288], [#835], [#1076]] - - - Added serving of tombstones of [FEP-ef61] portable objects and actors - through the gateway endpoint with `410 Gone`, as for ordinary objects, - so that other gateways and consumers can tell a deleted portable object - from one that the gateway never stored. A `Tombstone` that an object - dispatcher or the actor dispatcher returns for such a request is served - with `410 Gone` if its ID is the requested portable ID and it has - an Object Integrity Proof made with a key of the DID, and with - `404 Not Found` if it has no proof. Like other portable objects, - a tombstone from an object dispatcher without an authorization predicate - needs public addressing to be served. - [[#288], [#1113], [#1124], [#1153], [#1183]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-parse-uri.md b/changes.d/fedify/portable-parse-uri.md deleted file mode 100644 index abf5b302c..000000000 --- a/changes.d/fedify/portable-parse-uri.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -links: - '#1143': https://github.com/fedify-dev/fedify/issues/1143 - '#1145': https://github.com/fedify-dev/fedify/pull/1145 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the `portable` option to `Context.parseUri()`, and the - `ParseUriOptions` interface. With `{ portable: true }`, the method also - recognizes [FEP-ef61] portable IDs, e.g., - `ap+ef61://did:key:z6Mk.../users/alice`, and their compatible identifiers - on any gateway by the same paths through which the gateway endpoint serves - them, so that an inbox listener can tell that an activity is about one of - its portable actors, objects, or collections. The result then has the DID - of the ID in its new `authority` property. Since anyone can make - a portable ID with the same path under another DID, check that - `authority` is the DID that you store for the actor or object before - acting on the result. Without the option, `parseUri()` behaves as - before. [[#288], [#1143], [#1145]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-proof-policy-order.md b/changes.d/fedify/portable-proof-policy-order.md new file mode 100644 index 000000000..f052fa733 --- /dev/null +++ b/changes.d/fedify/portable-proof-policy-order.md @@ -0,0 +1,10 @@ +--- +links: + '#1177': https://github.com/fedify-dev/fedify/issues/1177 + '#1264': https://github.com/fedify-dev/fedify/pull/1264 +--- + - Changed compound portable-object verification to check each map's proof + policy before resolving its key or verifying its signature, avoiding work + for maps that will be rejected. Policy-rejected maps now appear in + `onRequestFinished` verification reports with their policy failure and no + signature checks. [[#1177], [#1264]] diff --git a/changes.d/fedify/portable-route-activity.md b/changes.d/fedify/portable-route-activity.md deleted file mode 100644 index 1041de198..000000000 --- a/changes.d/fedify/portable-route-activity.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -links: - '#1146': https://github.com/fedify-dev/fedify/issues/1146 - '#1171': https://github.com/fedify-dev/fedify/pull/1171 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed `Context.routeActivity()` to compare an activity that has no - valid proof with its actors by their [FEP-fe34] origins once it has - dereferenced the activity, where the origin of an [FEP-ef61] portable ID, - i.e., an `ap:` or `ap+ef61:` URI or a compatible identifier, is its DID - rather than the gateway that serves it. Previously, it compared their web - origins, so a portable activity was routed even if its actors had other - DIDs. Now a portable activity is routed only if all of its actors have - the same DID as the activity itself, and an activity with an ordinary - HTTP(S) ID is not routed if any of its actors has a portable ID. - A portable activity dereferenced through a compatible identifier also - matches its `ap:` ID, and vice versa. [[#288], [#1146], [#1171]] - -[FEP-fe34]: https://w3id.org/fep/fe34 -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/portable-webfinger.md b/changes.d/fedify/portable-webfinger.md deleted file mode 100644 index cb448454e..000000000 --- a/changes.d/fedify/portable-webfinger.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -links: - '#1082': https://github.com/fedify-dev/fedify/pull/1082 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#837': https://github.com/fedify-dev/fedify/issues/837 ---- - - Added WebFinger support for [FEP-ef61] portable actors. When an actor - dispatcher returns an actor whose ID is an `ap:` or `ap+ef61:` URI, the - WebFinger response takes the domain of the actor's `acct:` URI from the - first gateway in its `gateways`, as FEP-ef61 requires, and its `self` - link is the actor's compatible identifier based on that gateway, e.g., - `https://example.com/.well-known/apgateway/did:key:z6Mk.../actor`, so - that software without portable ID support can fetch it. A portable - actor without a valid first gateway is not found. Existing - `mapHandle()` and `mapAlias()` callbacks work as before, and WebFinger - resources can now be `ap:` or `ap+ef61:` URIs, which are passed to - `mapAlias()`. To serve the actor itself at its compatible identifier, - register an object dispatcher for it. [[#288], [#837], [#1082]] - - - `Context.lookupObject()` now uses `verifyPortableObjectProof()` as its - new `verifyPortableObject` option by default, so it looks up - [FEP-ef61] portable objects, including portable actors found through - WebFinger, through their gateways and returns them only if they have - valid Object Integrity Proofs made by the DIDs in their IDs. - [[#288], [#837], [#1082]] - - - Actor dispatchers no longer log warnings that a portable actor's ID or - collection URIs do not match the URIs that `Context` builds, such as - `Context.getActorUri()`. [[#288], [#837], [#1082]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/fedify/rfc9421-signature-cap.md b/changes.d/fedify/rfc9421-signature-cap.md deleted file mode 100644 index c1080ada0..000000000 --- a/changes.d/fedify/rfc9421-signature-cap.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -links: - '#1130': https://github.com/fedify-dev/fedify/issues/1130 - '#1166': https://github.com/fedify-dev/fedify/pull/1166 ---- - - Changed HTTP Signature verification to verify at most the first three - RFC 9421 signatures of a request, in the order of its `Signature-Input` - header, and to ignore the rest. Each signature may make Fedify fetch the - key that it names from a URL of the sender's choosing, so a single - unauthenticated request with many signatures could make Fedify fetch any - number of URLs. A signature counts even if it fails before its key is - fetched, and a key is now looked up only once for all the signatures of - a request that name it. A request whose only valid signature comes after - the first three is no longer accepted; senders usually put a single - signature on a request. [[#1130], [#1166]] - - - Added the `FederationOptions.maxHttpSignatures` option to change the - limit for the inbox and `RequestContext.getSignedKey()`, and the - `maxSignatures` option of `verifyRequest()` and - `verifyRequestDetailed()` for verifying requests directly. Both - have to be positive integers or `Infinity`, which turns the limit - off; other values throw a `RangeError`. - - - When a cached key does not verify a signature, Fedify now fetches it - again for that signature only, instead of verifying every signature - of the request once more without the key cache. diff --git a/changes.d/fedify/signed-child-representation.md b/changes.d/fedify/signed-child-representation.md deleted file mode 100644 index 3e1a532d3..000000000 --- a/changes.d/fedify/signed-child-representation.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -links: - '#1041': https://github.com/fedify-dev/fedify/pull/1041 - '#1044': https://github.com/fedify-dev/fedify/issues/1044 - '#1051': https://github.com/fedify-dev/fedify/pull/1051 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Fixed `signObject()` so that a signed object keeps verifying after it is - assigned to a typed parent and the parent is serialized. `signObject()` - now captures the secured JSON document its proof covers, and nested - serialization embeds that document verbatim instead of rebuilding the - child under the parent's JSON-LD context. Producing a [FEP-ef61] compound - document with `signObject()` no longer requires assembling the JSON by - hand. [[#288], [#1041], [#1044], [#1051]] - - - The captured document is a snapshot: `clone()` does not carry it, and - mutating a signed object in place does not change it. Sign a clone - again when it has to be embedded as a secured child. - - An object parsed with `fromJsonLd()`, an object that already carried a - proof, and a `toJsonLd()` `context` option that could hide the - internal placeholder all fall back to the previous behavior. - - Outgoing JSON-LD compatibility normalization now leaves a nested - self-contained secured document untouched while signing and sending, - so it cannot rewrite bytes that the child's own proof covers. - Inbound verification is unaffected. - - Fanout delivery now reuses the document the activity was already - serialized into instead of reparsing and reserializing it, which - previously invalidated an embedded signed child and the outer proof - that covered it. - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/init/imported-unreleased.md b/changes.d/init/imported-unreleased.md deleted file mode 100644 index 5d054c3a8..000000000 --- a/changes.d/init/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#950': https://github.com/fedify-dev/fedify/issues/950 - '#952': https://github.com/fedify-dev/fedify/pull/952 ---- - - Fixed `fedify init`'s hydration test validation to run `format` before - `format:check`, which previously caused the entire test suite to fail when - the package manager is `npm` or `pnpm`: - [[#950]] - [[#952] by Jang Hanarae\] diff --git a/changes.d/init/smoke-test.md b/changes.d/init/smoke-test.md deleted file mode 100644 index 29b6d3db8..000000000 --- a/changes.d/init/smoke-test.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#990': https://github.com/fedify-dev/fedify/pull/990 ---- - - Added a `test` task to projects scaffolded by `fedify init`. It starts - the app, waits for it to become ready, and checks that it resolves a local - actor, giving projects a standard smoke test to run right after scaffolding - and whenever the app changes afterwards. [[#898], [#990] by Jang Hanarae] diff --git a/changes.d/init/sveltekit.md b/changes.d/init/sveltekit.md deleted file mode 100644 index 817f01752..000000000 --- a/changes.d/init/sveltekit.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -links: - '#971': https://github.com/fedify-dev/fedify/pull/971 ---- - - Supported [SvelteKit] as a web framework option in - `fedify init`. [[#892], [#971] by Jang Hanarae] diff --git a/changes.d/init/verify-runtime-version.md b/changes.d/init/verify-runtime-version.md deleted file mode 100644 index de73aff1a..000000000 --- a/changes.d/init/verify-runtime-version.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#964': https://github.com/fedify-dev/fedify/issues/964 - '#981': https://github.com/fedify-dev/fedify/pull/981 ---- - - Added runtime version verification to `fedify init`. It checks that the - selected Deno, Bun, or Node.js meets Fedify's minimum version, or a higher - version required by a framework (such as Astro's Node.js 22.12), before - generating a project. A missing, malformed, or unsupported runtime now - produces a clear error in non-interactive mode and disables the affected - package managers in interactive mode. [[#964], [#981] by Lee Jeongmin] diff --git a/changes.d/interaction-controls/actorless-feature-requests.md b/changes.d/interaction-controls/actorless-feature-requests.md new file mode 100644 index 000000000..2b4d8764d --- /dev/null +++ b/changes.d/interaction-controls/actorless-feature-requests.md @@ -0,0 +1,26 @@ +--- +links: + '#1289': https://github.com/fedify-dev/fedify/issues/1289 + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - `featureInteraction.verifyRequest()` can now verify the [FEP-7aa9] + `FeatureRequest` activities Mastodon sends without an `actor`. Pass the + inbox listener's `InboxContext.authentication` as the new `authentication` + option; the requester is then the owner of the featured collection, who + must have been authenticated. [[#1289], [#1290], [#1299]] + + - Changed `featureInteraction.verifyRequest()` to always fetch the featured + collection from its ID and to take its owner from that document only: an + ordinary collection must be served from the origin of its ID, a portable + one must satisfy the FEP-ef61 policy, and it must have exactly one + `attributedTo` value. A collection embedded in the request, or given as + `resolvedInteractingObject`, is no longer trusted, because anyone could + attribute someone else's collection to themselves that way. A request + without an actor is no longer accepted on the strength of the collection's + `attributedTo` alone; without `authentication`, it fails with + `missingActor`. New failure types `credentialsInUrl`, + `invalidInteractingObject`, `ambiguousRequest`, and + `unauthenticatedRequester` describe the new checks. [[#1290], [#1299]] + +[FEP-7aa9]: https://w3id.org/fep/7aa9 diff --git a/changes.d/interaction-controls/imported-unreleased.md b/changes.d/interaction-controls/imported-unreleased.md deleted file mode 100644 index 840474f1f..000000000 --- a/changes.d/interaction-controls/imported-unreleased.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -links: - '#811': https://github.com/fedify-dev/fedify/issues/811 - '#929': https://github.com/fedify-dev/fedify/pull/929 ---- - - Added the new `@fedify/interaction-controls` package for implementing - [GoToSocial interaction controls], [FEP-044f], and [FEP-7aa9]. It provides - immutable TypeScript APIs for creating and verifying interaction requests - and authorizations, evaluating `InteractionPolicy`, recognizing bare - interactions, and formatting stable storage keys for like, reply, announce, - quote, and feature interactions. - [[#811], [#929]] - -[GoToSocial interaction controls]: https://docs.gotosocial.org/en/v0.21.1/federation/interaction_controls/ -[FEP-044f]: https://w3id.org/fep/044f -[FEP-7aa9]: https://w3id.org/fep/7aa9 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/lint/actor-preferred-username-rule.md b/changes.d/lint/actor-preferred-username-rule.md deleted file mode 100644 index 89b122b71..000000000 --- a/changes.d/lint/actor-preferred-username-rule.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#1022': https://github.com/fedify-dev/fedify/pull/1022 - '#895': https://github.com/fedify-dev/fedify/issues/895 ---- - - Added the `actor-preferred-username-required` lint rule, which warns - when an actor dispatcher's return value does not include a - `preferredUsername` property. [[#895], [#1022] by Jae-Hyuk-Jang] diff --git a/changes.d/lint/imported-unreleased.md b/changes.d/lint/imported-unreleased.md deleted file mode 100644 index 8ca0bffc9..000000000 --- a/changes.d/lint/imported-unreleased.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -links: - '#754': https://github.com/fedify-dev/fedify/issues/754 - '#927': https://github.com/fedify-dev/fedify/pull/927 ---- - - Added four lint rules for the media upload endpoint introduced in - `@fedify/fedify`: - [[#754], [#927]] - - - `media-uploader-object-uri-required` warns when a `setMediaUploader()` - callback does not derive its return value from `ctx.getObjectUri()`. - - `media-uploader-authorization-required` warns when `setMediaUploader()` - is registered without an `.authorize()` hook. - - `actor-upload-media-property-required` warns when a media uploader is - registered but the actor dispatcher does not advertise - `endpoints.uploadMedia`. - - `actor-upload-media-property-mismatch` warns when - `endpoints.uploadMedia` is not built with - `ctx.getMediaUploaderUri(identifier)`. diff --git a/changes.d/lint/outbox-delivery-not-awaited.md b/changes.d/lint/outbox-delivery-not-awaited.md deleted file mode 100644 index 8a4a93287..000000000 --- a/changes.d/lint/outbox-delivery-not-awaited.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -links: - '#1057': https://github.com/fedify-dev/fedify/issues/1057 - '#1067': https://github.com/fedify-dev/fedify/pull/1067 ---- - - Added the `outbox-listener-delivery-not-awaited` rule to `@fedify/lint`. - It reports an outbox listener that calls `ctx.sendActivity()` or - `ctx.forwardActivity()` and drops the returned promise, so that the - activity may never leave on a runtime such as Cloudflare Workers, which - discards pending work once the response is returned. A call counts as - handled when its promise is awaited, returned, passed to `Promise.all()` - or `Promise.allSettled()`, or handed to `waitUntil()`, and `void`, - `Promise.race()` and `Promise.any()` are accepted as deliberate choices to - stop waiting. - The ESLint `recommended` configuration enables the rule as a warning and - `strict` as an error, and Oxlint users enable it by name. It is not - available in Deno Lint, which turns on every rule of a plugin at once. - [[#1057], [#1067] by Jae-Hyuk-Jang] diff --git a/changes.d/lint/outbox-listener-nested-property-writes.md b/changes.d/lint/outbox-listener-nested-property-writes.md deleted file mode 100644 index 388cecdbb..000000000 --- a/changes.d/lint/outbox-listener-nested-property-writes.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -links: - '#1125': https://github.com/fedify-dev/fedify/issues/1125 - '#1140': https://github.com/fedify-dev/fedify/pull/1140 ---- - - Fixed `outbox-listener-delivery-required` and - `outbox-listener-delivery-not-awaited` (`@fedify/lint`) losing track of - the functions an object already holds when a nested helper in the - listener assigns another property of that object, as in - `target.fallback = () => {}`. A listener that delivers through - `target.deliver()` after such an assignment is no longer reported as - undelivered, and a dropped delivery promise inside `target.deliver()` is - now reported. A helper that declares its own object of the same name is - still checked separately from the outer one. [[#1125], [#1140]] diff --git a/changes.d/lint/outbox-listener-path-aware.md b/changes.d/lint/outbox-listener-path-aware.md deleted file mode 100644 index b54c302ba..000000000 --- a/changes.d/lint/outbox-listener-path-aware.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -links: - '#1050': https://github.com/fedify-dev/fedify/pull/1050 - '#900': https://github.com/fedify-dev/fedify/issues/900 ---- - - Changed `outbox-listener-delivery-required` (`@fedify/lint`) to decide - whether a `ctx.sendActivity()`/`ctx.forwardActivity()` call actually - runs, instead of scanning the listener's source as a flat block of text. - It now reports a listener whose only delivery calls sit behind a dead - branch, after an unconditional `return`/`throw`, or inside a function - that is never used. When it cannot tell whether a delivery call runs, it - stays quiet: a function held under a name counts as used as soon as that - name is mentioned, however it is passed around, and an inline callback - counts wherever it is passed. - [[#900], [#1050] by Jae-Hyuk-Jang] diff --git a/changes.d/lint/outbox-listener-reachability-loops.md b/changes.d/lint/outbox-listener-reachability-loops.md deleted file mode 100644 index d517814c7..000000000 --- a/changes.d/lint/outbox-listener-reachability-loops.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -links: - '#1071': https://github.com/fedify-dev/fedify/issues/1071 - '#1088': https://github.com/fedify-dev/fedify/pull/1088 ---- - - Fixed `outbox-listener-delivery-required` and - `outbox-listener-delivery-not-awaited` (`@fedify/lint`) to properly - evaluate reachability in statically false loops (like `while (false)`) and - to correctly traverse `for...of` and `for...in` loop binding patterns. - Unreachable loop branches no longer count as deliveries, and - `outbox-listener-delivery-not-awaited` now correctly catches dropped - promises inside loop binding patterns. - Loop default functions are checked only when the bound value or target - object is referenced. Delivery helpers used after an assignment-form - loop default remain recognized, including when unrelated blocks reuse - their names. - [[#1071], [#1088] by @ArchieTansaria] diff --git a/changes.d/lint/portable-uri-mismatches.md b/changes.d/lint/portable-uri-mismatches.md deleted file mode 100644 index 8259c8bd4..000000000 --- a/changes.d/lint/portable-uri-mismatches.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -links: - '#1157': https://github.com/fedify-dev/fedify/issues/1157 - '#1179': https://github.com/fedify-dev/fedify/pull/1179 ---- - - Fixed actor URI mismatch rules reporting portable actor and collection - URIs, including gateway-compatible IDs built from them. Applications can - use the `getPortable*Uri()` helpers in actor dispatchers without disabling - these rules. [[#1157], [#1179]] diff --git a/changes.d/mysql/imported-unreleased.md b/changes.d/mysql/imported-unreleased.md deleted file mode 100644 index 1dc944800..000000000 --- a/changes.d/mysql/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS MySQL adapter build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/netlify/blobs-kv-store.md b/changes.d/netlify/blobs-kv-store.md deleted file mode 100644 index 2e71283a2..000000000 --- a/changes.d/netlify/blobs-kv-store.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#1029': https://github.com/fedify-dev/fedify/pull/1029 ---- - - Added `NetlifyBlobsKvStore`, a Netlify Blobs-backed key–value store with - expiration, prefix listing, and atomic compare-and-set operations. Netlify - deployments can now persist Fedify state and preserve ordered queue - delivery without a separate database. [[#1010], [#1029] by Jiwon Kwon] diff --git a/changes.d/netlify/imported-unreleased.md b/changes.d/netlify/imported-unreleased.md deleted file mode 100644 index f8e1ffc78..000000000 --- a/changes.d/netlify/imported-unreleased.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#930': https://github.com/fedify-dev/fedify/issues/930 - '#934': https://github.com/fedify-dev/fedify/pull/934 ---- - - Added the new *@fedify/netlify* package for processing Fedify message queue - jobs with Netlify Async Workloads. It provides `NetlifyMessageQueue` for - durable event submission and `createNetlifyQueueHandler()` for Netlify - Function consumers, including delayed delivery, native retry delegation, - non-retryable malformed-event handling, durable per-key FIFO ordering, and - explicit recovery for unobservable dead-letter failures. - [[#930], [#934]] diff --git a/changes.d/next.txt b/changes.d/next.txt index 197c4d5c2..437459cd9 100644 --- a/changes.d/next.txt +++ b/changes.d/next.txt @@ -1 +1 @@ -2.4.0 +2.5.0 diff --git a/changes.d/next/hashlink-media-requests.md b/changes.d/next/hashlink-media-requests.md deleted file mode 100644 index a0bc2a56c..000000000 --- a/changes.d/next/hashlink-media-requests.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -links: - '#1149': https://github.com/fedify-dev/fedify/issues/1149 - '#1170': https://github.com/fedify-dev/fedify/pull/1170 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added support for [FEP-ef61] hashlink media requests, e.g., - `GET /.well-known/apgateway/hl:zQm...`, to `fedifyWith()`. Clients fetch - such media with, e.g., `Accept: image/*`, so these requests were not - passed to Fedify unless they had federation media types in their headers, - and Next.js answered them instead of the hashlink media dispatcher. - `isFederationRequest()` now recognizes them by their paths regardless of - their headers. To make Next.js run the middleware for them, add - `{ source: "/.well-known/apgateway/:path*" }` to the `matcher` of your - *middleware.ts* or *proxy.ts* file. [[#288], [#1149], [#1170]] - - - Added `isHashlinkMediaRequest()` function. [[#288], [#1149], [#1170]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/pglite/pglite-kv-store.md b/changes.d/pglite/pglite-kv-store.md deleted file mode 100644 index b8b37c1c9..000000000 --- a/changes.d/pglite/pglite-kv-store.md +++ /dev/null @@ -1,5 +0,0 @@ - - Added the `@fedify/pglite` package with `PgliteKvStore`, a `KvStore` backed - by an embedded PGlite database. It accepts a caller-created PGlite instance - and is intended for a single PGlite instance in a single runtime isolate; a - message queue is not provided because PGlite does not share data between - processes. [[#1018], [#1020] by ChanHaeng Lee] diff --git a/changes.d/postgres/imported-unreleased.md b/changes.d/postgres/imported-unreleased.md deleted file mode 100644 index 7f3af8368..000000000 --- a/changes.d/postgres/imported-unreleased.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 - '#930': https://github.com/fedify-dev/fedify/issues/930 - '#934': https://github.com/fedify-dev/fedify/pull/934 ---- - - Added `PostgresKvStore.cas()`, including atomic creation, replacement, and - deletion with TTL-aware comparison. This allows PostgreSQL-backed stores, - including Netlify Database, to enforce queue ordering and other Fedify CAS - operations. - [[#930], [#934]] - - `PostgresKvStore` now creates crash-safe logged tables by default and - migrates existing unlogged tables during initialization. Transient - unlogged storage remains available with the `unlogged` option. The - one-time migration rewrites and exclusively locks an existing table, so - upgrades with large or busy key–value tables should schedule it - accordingly. - [[#930], [#934]] - - Fixed the CommonJS PostgreSQL adapter build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/redis/atomic-kv-swaps.md b/changes.d/redis/atomic-kv-swaps.md deleted file mode 100644 index 1fe10c9b9..000000000 --- a/changes.d/redis/atomic-kv-swaps.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1163': https://github.com/fedify-dev/fedify/issues/1163 - '#1167': https://github.com/fedify-dev/fedify/pull/1167 ---- - - Added atomic `RedisKvStore.cas()` for standalone Redis and Redis Cluster. - Redis-backed deployments can now use portable inbox forwarding and other - features that need compare-and-swap. Custom codecs must encode equal - values identically, and Redis must permit `EVAL`; deployments that deny - scripting can no longer rely on the previous non-CAS fallback. - [[#1163], [#1167]] diff --git a/changes.d/redis/imported-unreleased.md b/changes.d/redis/imported-unreleased.md deleted file mode 100644 index d37f4d8a2..000000000 --- a/changes.d/redis/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS Redis adapter build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/relay/clarify-relay-contract.md b/changes.d/relay/clarify-relay-contract.md deleted file mode 100644 index d3bd5d05d..000000000 --- a/changes.d/relay/clarify-relay-contract.md +++ /dev/null @@ -1,4 +0,0 @@ - - Fixed the relay documentation to use the canonical actor and shared inbox - URIs, distinguish Mastodon-style and LitePub-style subscription behavior, - and explain which deployment responsibilities remain with applications. - [[#899], [#996] by Jiwon Kwon] diff --git a/changes.d/relay/imported-unreleased.md b/changes.d/relay/imported-unreleased.md deleted file mode 100644 index f2e89928a..000000000 --- a/changes.d/relay/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS relay build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/sqlite/imported-unreleased.md b/changes.d/sqlite/imported-unreleased.md deleted file mode 100644 index 5ab8fbfca..000000000 --- a/changes.d/sqlite/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS SQLite adapter build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/testing/hashlink-media-dispatcher.md b/changes.d/testing/hashlink-media-dispatcher.md deleted file mode 100644 index e34878b31..000000000 --- a/changes.d/testing/hashlink-media-dispatcher.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#1080': https://github.com/fedify-dev/fedify/pull/1080 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#838': https://github.com/fedify-dev/fedify/issues/838 ---- - - Added the `setHashlinkMediaDispatcher()` method to the `Federation` - object returned by `createFederation()`, following the new - `Federatable.setHashlinkMediaDispatcher()` method of `@fedify/fedify`. - [[#288], [#838], [#1080]] diff --git a/changes.d/testing/imported-unreleased.md b/changes.d/testing/imported-unreleased.md deleted file mode 100644 index af3f6ca4f..000000000 --- a/changes.d/testing/imported-unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#925': https://github.com/fedify-dev/fedify/pull/925 ---- - - Fixed the CommonJS testing utilities build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] diff --git a/changes.d/testing/inbox-authentication.md b/changes.d/testing/inbox-authentication.md new file mode 100644 index 000000000..044456fce --- /dev/null +++ b/changes.d/testing/inbox-authentication.md @@ -0,0 +1,8 @@ +--- +links: + '#1290': https://github.com/fedify-dev/fedify/issues/1290 + '#1299': https://github.com/fedify-dev/fedify/pull/1299 +--- + - Mock inbox contexts made by `createInboxContext()` now have the + `authentication` property, which can be given as an option and defaults + to evidence that nothing was authenticated. [[#1290], [#1299]] diff --git a/changes.d/testing/is-signed-by-audience.md b/changes.d/testing/is-signed-by-audience.md deleted file mode 100644 index 5e65bc242..000000000 --- a/changes.d/testing/is-signed-by-audience.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1153': https://github.com/fedify-dev/fedify/issues/1153 - '#1183': https://github.com/fedify-dev/fedify/pull/1183 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the `isSignedByAudience()` method to the mock contexts that - `createFederation()` and `createRequestContext()` create, following - the new `RequestContext.isSignedByAudience()` method of `@fedify/fedify`. - It checks the audience against the actor that `getSignedKeyOwner()` - returns. [[#288], [#1153], [#1183]] diff --git a/changes.d/testing/kv-store-tester.md b/changes.d/testing/kv-store-tester.md deleted file mode 100644 index 994b4eb9d..000000000 --- a/changes.d/testing/kv-store-tester.md +++ /dev/null @@ -1,3 +0,0 @@ - - Added `testKvStore()`, a conformance test suite for `KvStore` - implementations, complementing `testMessageQueue()`. - [[#1018], [#1020] by ChanHaeng Lee] diff --git a/changes.d/testing/object-tombstones.md b/changes.d/testing/object-tombstones.md deleted file mode 100644 index a0550aece..000000000 --- a/changes.d/testing/object-tombstones.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#1112': https://github.com/fedify-dev/fedify/issues/1112 - '#1117': https://github.com/fedify-dev/fedify/pull/1117 ---- - - Changed `getObject()` of the mock context that `createFederation()` - creates to follow `RequestContext.getObject()` of `@fedify/fedify`: it - now returns `null` for a `Tombstone` that the object dispatcher returns, - unless the tombstone is an instance of the requested class or - `{ tombstone: "passthrough" }` is given. [[#1112], [#1117]] diff --git a/changes.d/testing/portable-actor-uri.md b/changes.d/testing/portable-actor-uri.md deleted file mode 100644 index dfbbe965b..000000000 --- a/changes.d/testing/portable-actor-uri.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1114': https://github.com/fedify-dev/fedify/pull/1114 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#841': https://github.com/fedify-dev/fedify/issues/841 ---- - - Added the `getPortableActorUri()` method to the mock contexts that - `createFederation()`, `createContext()`, `createRequestContext()`, - `createInboxContext()`, and `createOutboxContext()` create, following - the new `Context.getPortableActorUri()` method of `@fedify/fedify`. - The mock contexts' portable ID methods now also reject `did:key` DIDs - that are not encoded in base58-btc. [[#288], [#841], [#1114]] diff --git a/changes.d/testing/portable-chain-verifiers.md b/changes.d/testing/portable-chain-verifiers.md deleted file mode 100644 index 762236dba..000000000 --- a/changes.d/testing/portable-chain-verifiers.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1107': https://github.com/fedify-dev/fedify/issues/1107 - '#1120': https://github.com/fedify-dev/fedify/pull/1120 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - The mock contexts that `createContext()` creates now keep the - `verifyPortableObject` property given to them, following the new - `Context.verifyPortableObject` property of `@fedify/fedify`, and their - default `lookupObject()` and `traverseCollection()` methods pass it and - a `verifyPortableObject` option given to them on. - [[#288], [#1107], [#1120]] diff --git a/changes.d/testing/portable-collection-uris.md b/changes.d/testing/portable-collection-uris.md deleted file mode 100644 index f7b7c4508..000000000 --- a/changes.d/testing/portable-collection-uris.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1111': https://github.com/fedify-dev/fedify/issues/1111 - '#1142': https://github.com/fedify-dev/fedify/pull/1142 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the `getPortableOutboxUri()`, `getPortableFollowingUri()`, - `getPortableFollowersUri()`, `getPortableLikedUri()`, - `getPortableFeaturedUri()`, `getPortableFeaturedTagsUri()`, and - `getPortableCollectionUri()` methods to the mock contexts, and - the `mapPortableOwner()` method to the mock custom collection setters, - following the new APIs of `@fedify/fedify`. [[#288], [#1111], [#1142]] diff --git a/changes.d/testing/portable-gateway-keys.md b/changes.d/testing/portable-gateway-keys.md deleted file mode 100644 index 49e9d5194..000000000 --- a/changes.d/testing/portable-gateway-keys.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1099': https://github.com/fedify-dev/fedify/pull/1099 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#840': https://github.com/fedify-dev/fedify/issues/840 ---- - - Added the `mapPortableActorId()` method to the setters that - `MockFederation.setActorDispatcher()` returns, which `ActorCallbackSetters` - gained for [FEP-ef61] portable actors. [[#288], [#840], [#1099]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/testing/portable-inbox-uri.md b/changes.d/testing/portable-inbox-uri.md deleted file mode 100644 index bfc5c78a0..000000000 --- a/changes.d/testing/portable-inbox-uri.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1092': https://github.com/fedify-dev/fedify/pull/1092 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#839': https://github.com/fedify-dev/fedify/issues/839 ---- - - Added the `getPortableInboxUri()` method to the mock contexts that - `createFederation()`, `createContext()`, `createRequestContext()`, - `createInboxContext()`, and `createOutboxContext()` create, following - the new `Context.getPortableInboxUri()` method of `@fedify/fedify`. - [[#288], [#839], [#1092]] diff --git a/changes.d/testing/portable-mock-requests.md b/changes.d/testing/portable-mock-requests.md deleted file mode 100644 index 264ac7f3f..000000000 --- a/changes.d/testing/portable-mock-requests.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1161': https://github.com/fedify-dev/fedify/issues/1161 - '#1196': https://github.com/fedify-dev/fedify/pull/1196 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added fixture document loaders and portable-object verification to mock - context lookups, explicit portable gateway request information for mock - dispatchers, and hashlink media responses from mock federations. Tests can - now exercise these FEP-ef61 paths without a live gateway. - [[#288], [#1161], [#1196]] diff --git a/changes.d/testing/portable-parse-uri.md b/changes.d/testing/portable-parse-uri.md deleted file mode 100644 index 6081317bb..000000000 --- a/changes.d/testing/portable-parse-uri.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1143': https://github.com/fedify-dev/fedify/issues/1143 - '#1145': https://github.com/fedify-dev/fedify/pull/1145 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed `parseUri()` of the mock contexts to accept the `portable` option, - following `Context.parseUri()` of `@fedify/fedify`. It no longer - recognizes an FEP-ef61 portable ID or compatible identifier whose path - starts with `/users/` unless the option is enabled, in which case the - result has the DID in its `authority` property. It also returns `null` - for `null`. [[#288], [#1143], [#1145]] diff --git a/changes.d/vocab-runtime/activitystreams-accept-header.md b/changes.d/vocab-runtime/activitystreams-accept-header.md deleted file mode 100644 index 1a01824c0..000000000 --- a/changes.d/vocab-runtime/activitystreams-accept-header.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -links: - '#1077': https://github.com/fedify-dev/fedify/pull/1077 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#834': https://github.com/fedify-dev/fedify/issues/834 ---- - - Changed the `Accept` header that document loaders send when fetching - ActivityPub objects to - `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, - as ActivityPub and [FEP-ef61] gateways require. Previously, the JSON-LD - media type lacked the ActivityStreams profile. [[#288], [#834], [#1077]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab-runtime/compatible-ef61-ids.md b/changes.d/vocab-runtime/compatible-ef61-ids.md deleted file mode 100644 index dae4399d3..000000000 --- a/changes.d/vocab-runtime/compatible-ef61-ids.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -links: - '#1074': https://github.com/fedify-dev/fedify/pull/1074 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#833': https://github.com/fedify-dev/fedify/issues/833 ---- - - Added `toCompatibleEf61Id()` and `fromCompatibleEf61Id()` for converting - between [FEP-ef61] portable IDs and compatible identifiers, which are - HTTP(S) URLs under a gateway's fixed `/.well-known/apgateway/` path that - software without portable IRI support can use. `toCompatibleEf61Id()` - removes location hints (`@gateway` query parameters, and the legacy - `gateways` parameter), and `fromCompatibleEf61Id()` returns - `null` for URLs that are not compatible identifiers and throws a - `TypeError` for malformed ones, including those with location hints. - Converting a compatible identifier does not authenticate it; the object's - proof still has to be verified against its DID. [[#288], [#833], [#1074]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab-runtime/document-loader-timeout.md b/changes.d/vocab-runtime/document-loader-timeout.md deleted file mode 100644 index f3fea168c..000000000 --- a/changes.d/vocab-runtime/document-loader-timeout.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -links: - '#1131': https://github.com/fedify-dev/fedify/issues/1131 - '#1169': https://github.com/fedify-dev/fedify/pull/1169 ---- - - Changed `getDocumentLoader()` to time out each call after 10 seconds by - default. The time limit covers the whole call, including every redirect - and alternate document it follows and reading the response body. - A call that times out throws a `FetchError` without a response, whose - `cause` is a `DOMException` named `"TimeoutError"`. An `AbortSignal` - passed as the `signal` option still cancels a call as before. - [[#1131], [#1169]] - - - Added `DocumentLoaderFactoryOptions.timeout` option, in milliseconds, - to change the time limit, or to turn it off with `null`. - - - Changed `getDocumentLoader()` to read the body of an error response - before it throws a `FetchError`, at most 1 MiB, so that the time limit - also bounds reading it. `FetchError.response` keeps the body byte for - byte, or only the status and headers if the body is larger than that. - [[#1131], [#1169]] diff --git a/changes.d/vocab-runtime/gateway-hints.md b/changes.d/vocab-runtime/gateway-hints.md deleted file mode 100644 index e9298c643..000000000 --- a/changes.d/vocab-runtime/gateway-hints.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -links: - '#1159': https://github.com/fedify-dev/fedify/issues/1159 - '#1189': https://github.com/fedify-dev/fedify/pull/1189 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added `withGatewayHints()`, `withoutGatewayHints()`, and - `getGatewayHints()` to add, remove, and read the [FEP-ef61] `@gateway` - location hints of portable IDs, which tell consumers where to retrieve - a referenced portable actor. Use `withGatewayHints()` when referring to - a portable actor, e.g., in `attributedTo` or `to`, instead of editing - the query by hand; it writes each gateway's URI-encoded origin as - FEP-ef61 requires, replaces existing hints, and keeps the other query - parameters. Do not add hints to an object's own `id`. - [[#288], [#1159], [#1189]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab-runtime/imported-unreleased.md b/changes.d/vocab-runtime/imported-unreleased.md deleted file mode 100644 index 2918accf5..000000000 --- a/changes.d/vocab-runtime/imported-unreleased.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -links: - '#1151': https://github.com/fedify-dev/fedify/issues/1151 - '#1198': https://github.com/fedify-dev/fedify/pull/1198 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#810': https://github.com/fedify-dev/fedify/issues/810 - '#827': https://github.com/fedify-dev/fedify/issues/827 - '#828': https://github.com/fedify-dev/fedify/issues/828 - '#829': https://github.com/fedify-dev/fedify/issues/829 - '#830': https://github.com/fedify-dev/fedify/issues/830 - '#831': https://github.com/fedify-dev/fedify/issues/831 - '#912': https://github.com/fedify-dev/fedify/issues/912 - '#913': https://github.com/fedify-dev/fedify/pull/913 - '#914': https://github.com/fedify-dev/fedify/pull/914 - '#915': https://github.com/fedify-dev/fedify/pull/915 - '#924': https://github.com/fedify-dev/fedify/pull/924 - '#926': https://github.com/fedify-dev/fedify/pull/926 - '#928': https://github.com/fedify-dev/fedify/pull/928 - '#935': https://github.com/fedify-dev/fedify/pull/935 ---- - - Added SHA-256 `digestMultibase` and simple `hl:` hashlink helpers for - computing, parsing, creating, and verifying portable media resource - digests as required by [FEP-ef61]. - [[#831], [#935]] - - - Added the [FEP-ef61] JSON-LD context to the preloaded context registry so - portable actor and media documents can compact and expand `gateways` and - `digestMultibase` without fetching the context remotely. - [[#830], [#928]] - - - Added `getFe34Origin()` and `haveSameFe34Origin()` for comparing ordinary - web origins and [FEP-ef61] cryptographic origins with one shared - [FEP-fe34] helper. HTTP(S) URLs keep web-origin semantics, while - `ap:`/`ap+ef61:` URIs and DID URLs use their DID component as the origin. - [[#829], [#926]] - - - Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for - comparing [FEP-ef61] portable ActivityPub URI strings. The helpers accept - `ap:` and `ap+ef61:` values with decoded or percent-encoded DID - authorities, normalize them to `ap+ef61:`, and ignore query hints such as - `gateways` during comparison. - [[#828], [#924]] - - Canonicalizing to `ap+ef61:` rather than `ap:`, which FEP-ef61 currently - recommends, is a deliberate choice of Fedify's that may change in - Fedify 3.0. Compare portable IDs with `arePortableUrisEqual()` rather - than as strings. See the [*Portable objects*][portable objects] chapter - of the manual for the FEP-ef61 profile that Fedify supports. - [[#288], [#1151], [#1198]] - - - Added the [FEP-7aa9] JSON-LD context to the preloaded context registry so - FEP-7aa9 documents can be compacted and expanded without fetching the - context remotely. - [[#810], [#914]] - - - Added helpers for Ed25519 `did:key` DIDs and verification method DID - URLs: `exportDidKey()` exports public keys to base58-btc `did:key` DIDs, - `importDidKey()` imports supported DIDs back to `CryptoKey`, and - `parseDidKeyVerificationMethod()` validates `did:key:z...#z...` - verification methods. - [[#827], [#915]] - - - Changed `getDocumentLoader()` to reject HTML and XHTML responses that do - not advertise an ActivityPub alternate document with a `FetchError` - instead of attempting to parse the HTML as JSON. This makes remote HTML - error pages surface as document loading failures with the response URL and - content type, rather than generic JSON parser crashes. - [[#912], [#913]] - -[FEP-ef61]: https://w3id.org/fep/ef61 -[FEP-fe34]: https://w3id.org/fep/fe34 -[portable objects]: https://fedify.dev/manual/portable -[FEP-7aa9]: https://w3id.org/fep/7aa9 diff --git a/changes.d/vocab-runtime/normalize-language-tag.md b/changes.d/vocab-runtime/normalize-language-tag.md new file mode 100644 index 000000000..cdb2407d6 --- /dev/null +++ b/changes.d/vocab-runtime/normalize-language-tag.md @@ -0,0 +1,10 @@ +--- +links: + '#1229': https://github.com/fedify-dev/fedify/issues/1229 + '#1231': https://github.com/fedify-dev/fedify/pull/1231 +--- + - 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 + applications can normalize such tags before passing them to it. + [[#1229], [#1231]] diff --git a/changes.d/vocab-runtime/portable-chain-verifiers.md b/changes.d/vocab-runtime/portable-chain-verifiers.md deleted file mode 100644 index 014b3fd74..000000000 --- a/changes.d/vocab-runtime/portable-chain-verifiers.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1107': https://github.com/fedify-dev/fedify/issues/1107 - '#1120': https://github.com/fedify-dev/fedify/pull/1120 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the `verifyPortableObject` property to - `PropertyPreprocessorContext`, the default [FEP-ef61] portable object - verifier that objects returned by a property preprocessor should use for - their property accessors. [[#288], [#1107], [#1120]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab-runtime/portable-dot-segments.md b/changes.d/vocab-runtime/portable-dot-segments.md deleted file mode 100644 index 1452cf2fd..000000000 --- a/changes.d/vocab-runtime/portable-dot-segments.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -links: - '#1154': https://github.com/fedify-dev/fedify/issues/1154 - '#1186': https://github.com/fedify-dev/fedify/pull/1186 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Fixed portable and gateway-compatible ID strings with `.` or `..` path - segments being interpreted as different IDs after URL parsing. Fedify now - rejects these strings because the segments cannot be preserved by its - URL-based APIs. Raw portable ID strings can still be compared without - losing their paths. [[#288], [#1154], [#1186]] diff --git a/changes.d/vocab-runtime/portable-object-verifier.md b/changes.d/vocab-runtime/portable-object-verifier.md deleted file mode 100644 index 41fa16f07..000000000 --- a/changes.d/vocab-runtime/portable-object-verifier.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -links: - '#1077': https://github.com/fedify-dev/fedify/pull/1077 - '#1084': https://github.com/fedify-dev/fedify/pull/1084 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#834': https://github.com/fedify-dev/fedify/issues/834 - '#836': https://github.com/fedify-dev/fedify/issues/836 ---- - - Added the `PortableObjectVerifier`, `PortableObjectVerifierOptions`, - `PortableObjectVerification`, and `PortableObjectReferrer` types, which - describe the `verifyPortableObject` option of property accessors for - [FEP-ef61] portable references. A verifier receives the fetched - document along with its final URL, the gateways used, and the chain of - objects that referred to it, and can accept a document without an - integrity proof as `unsecured`. `verifyPortableObject()` and - `verifyPortableObjectProof()` from `@fedify/fedify` satisfy - `PortableObjectVerifier`. [[#288], [#834], [#836], [#1077], [#1084]] - -[FEP-ef61]: https://w3id.org/fep/ef61 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/changes.d/vocab-runtime/translation-context.md b/changes.d/vocab-runtime/translation-context.md deleted file mode 100644 index 97e8314d1..000000000 --- a/changes.d/vocab-runtime/translation-context.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -links: - '#1037': https://github.com/fedify-dev/fedify/issues/1037 - '#1038': https://github.com/fedify-dev/fedify/pull/1038 ---- - - Added to preloaded JSON-LD contexts. - [[#1037], [#1038]] diff --git a/changes.d/vocab-runtime/verified-portable-media.md b/changes.d/vocab-runtime/verified-portable-media.md deleted file mode 100644 index 8faa66235..000000000 --- a/changes.d/vocab-runtime/verified-portable-media.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -links: - '#1152': https://github.com/fedify-dev/fedify/issues/1152 - '#1182': https://github.com/fedify-dev/fedify/pull/1182 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added `fetchPortableMedia()` to retrieve [FEP-ef61] hashlink media through - an actor's gateways or HTTP(S) media directly, and return the resource only - after verifying its `digestMultibase`. It rejects missing or mismatched - digests, limits response size, and checks private network addresses by - default. [[#288], [#1152], [#1182]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab-tools/absolute-iri.md b/changes.d/vocab-tools/absolute-iri.md new file mode 100644 index 000000000..e61b63f75 --- /dev/null +++ b/changes.d/vocab-tools/absolute-iri.md @@ -0,0 +1,15 @@ +--- +links: + '#1212': https://github.com/fedify-dev/fedify/issues/1212 + '#1241': https://github.com/fedify-dev/fedify/pull/1241 +--- + - Added the `fedify:absoluteIri` range for URLs written as IRI references + that also accept string literals when read. Unlike + `http://www.w3.org/2001/XMLSchema#anyURI`, it never resolves a value + against the object's ID, and skips values that are not absolute instead of + failing the whole object. [[#1212], [#1241]] + - Changed `extraContext` to add its context when the property is populated + even if the default context defines a matching prefix, such as `dc` in + `https://w3id.org/identity/v1`. Previously, such a property compacted to + a prefixed name like `dc:license` without the extra context. + [[#1212], [#1241]] diff --git a/changes.d/vocab-tools/conditional-contexts.md b/changes.d/vocab-tools/conditional-contexts.md deleted file mode 100644 index fb9712bd5..000000000 --- a/changes.d/vocab-tools/conditional-contexts.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#1037': https://github.com/fedify-dev/fedify/issues/1037 - '#1038': https://github.com/fedify-dev/fedify/pull/1038 ---- - - Added the `extraContext` property schema option to include a JSON-LD - context only when its terms are used, preserving existing output for - objects without the extension. [[#1037], [#1038]] - - Added the `trustEmbeddedObjects` type schema option so embedded metadata - identifiers need not establish trust in linked actors. [[#1037], [#1038]] diff --git a/changes.d/vocab-tools/redundant-properties.md b/changes.d/vocab-tools/redundant-properties.md new file mode 100644 index 000000000..96862c2b7 --- /dev/null +++ b/changes.d/vocab-tools/redundant-properties.md @@ -0,0 +1,11 @@ +--- +links: + '#1210': https://github.com/fedify-dev/fedify/issues/1210 + '#1215': https://github.com/fedify-dev/fedify/pull/1215 +--- + - Added non-functional `redundantProperties` support with ordered fallback + to the first non-empty value set, preserving lists and graphs without + merging synonyms. Added `redundantPropertiesWrite: canonical` to write + only the canonical property while accepting synonyms. Fixed serialization + of synonyms without a compact name and validation of synonym definitions. + [[#1210], [#1215]] diff --git a/changes.d/vocab-tools/suppressed-error-log-level.md b/changes.d/vocab-tools/suppressed-error-log-level.md deleted file mode 100644 index 5962d3cbd..000000000 --- a/changes.d/vocab-tools/suppressed-error-log-level.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -links: - '#1035': https://github.com/fedify-dev/fedify/pull/1035 - '#933': https://github.com/fedify-dev/fedify/issues/933 ---- - - Changed suppressed vocabulary fetch and parsing failures to log at the - warning level so that intentionally handled failures are not reported as - application errors. [[#933], [#1035] by Jae Hui Hong] diff --git a/changes.d/vocab/compatible-id-dereferencing.md b/changes.d/vocab/compatible-id-dereferencing.md deleted file mode 100644 index 79173e1bc..000000000 --- a/changes.d/vocab/compatible-id-dereferencing.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -links: - '#1090': https://github.com/fedify-dev/fedify/issues/1090 - '#1091': https://github.com/fedify-dev/fedify/pull/1091 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Property accessors such as `Create.getObject()` now dereference - [FEP-ef61] compatible identifiers, i.e., HTTP(S) URLs under a gateway's - */.well-known/apgateway/* path, as the portable objects they stand for - when the `verifyPortableObject` option is given or the parent object is - portable. Previously, they fetched such a reference as an ordinary - HTTP(S) URL and trusted the result because of its origin, so anyone could - serve an unsigned object that claimed to be someone else's portable - object. Now the accessor asks the gateway that the identifier names - first, and then the gateways in the `gateways` option, and returns the - object only if its `@id` identifies the same portable object and - `verifyPortableObject` accepts it, including the gateway trust policy for - unsecured collections. Malformed compatible identifiers are rejected - without a request. [[#288], [#1090], [#1091]] - - - Property accessors and `lookupObject()` with the `verifyPortableObject` - option now verify a document fetched from an ordinary HTTP(S) URL as - a portable object if its final URL is a compatible identifier or its - `@id` is an `ap:`/`ap+ef61:` URI, instead of trusting it because of its - origin. Likewise, accessors no longer trust an embedded object whose - `@id` is a compatible identifier, or a portable ID with a DID other than - its parent's, even with `crossOrigin: "trust"`; they dereference and - verify it on its own. [[#288], [#1090], [#1091]] - - - Without the `verifyPortableObject` option, property accessors keep - fetching compatible identifiers from ordinary objects as HTTP(S) URLs, - but no longer cache the results in the parent objects, so that a later - call with the option verifies them. [[#288], [#1090], [#1091]] - - - The gateways that `lookupObject()` infers from compatible identifiers, - WebFinger responses, and location hints are now passed to the - `verifyPortableObject` function as `gatewayHints` instead of `gateways`, - which are reserved for gateways that the caller gives explicitly. - [[#288], [#1090], [#1091]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/compatible-id-documents.md b/changes.d/vocab/compatible-id-documents.md deleted file mode 100644 index 546efe36e..000000000 --- a/changes.d/vocab/compatible-id-documents.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -links: - '#1093': https://github.com/fedify-dev/fedify/issues/1093 - '#1105': https://github.com/fedify-dev/fedify/pull/1105 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Property accessors and `lookupObject()` with the `verifyPortableObject` - option now accept a portable object whose `@id` is an [FEP-ef61] - compatible identifier of the requested portable object, e.g., one on the - gateway that served it, and pass it to `verifyPortableObject`, keeping its - own `@id`. Previously, such documents, which some implementations such - as tootik publish, were rejected as objects with a mismatching ID. - [[#288], [#1093], [#1105]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/content-license.md b/changes.d/vocab/content-license.md new file mode 100644 index 000000000..11ced6db4 --- /dev/null +++ b/changes.d/vocab/content-license.md @@ -0,0 +1,23 @@ +--- +links: + '#1212': https://github.com/fedify-dev/fedify/issues/1212 + '#1241': https://github.com/fedify-dev/fedify/pull/1241 +--- + - Added vocabulary support for the [FEP-6757] draft, which marks + ActivityPub content with explicit copyright license URIs. + [[#1212], [#1241]] + + - Added `Object.licenses` and `Link.licenses` properties for the + alternative licenses of an object or a link. They write the Dublin + Core `license` property and also read the Schema.org and Creative + Commons `license` properties when it is absent. Values that are not + absolute URIs, such as license names or SPDX short identifiers, are + skipped. + - Added `preferredLicense` property to `Application`, `Group`, + `Organization`, `Person`, and `Service` for the license an actor + prefers for its new objects. It is not a fallback for objects without + their own license. + - Fedify does not fill in a default license for objects without license + metadata and never fetches a license URI. + +[FEP-6757]: https://w3id.org/fep/6757 diff --git a/changes.d/vocab/imported-unreleased.md b/changes.d/vocab/imported-unreleased.md deleted file mode 100644 index b5b676e22..000000000 --- a/changes.d/vocab/imported-unreleased.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -links: - '#754': https://github.com/fedify-dev/fedify/issues/754 - '#810': https://github.com/fedify-dev/fedify/issues/810 - '#823': https://github.com/fedify-dev/fedify/issues/823 - '#826': https://github.com/fedify-dev/fedify/issues/826 - '#829': https://github.com/fedify-dev/fedify/issues/829 - '#830': https://github.com/fedify-dev/fedify/issues/830 - '#850': https://github.com/fedify-dev/fedify/pull/850 - '#914': https://github.com/fedify-dev/fedify/pull/914 - '#925': https://github.com/fedify-dev/fedify/pull/925 - '#926': https://github.com/fedify-dev/fedify/pull/926 - '#927': https://github.com/fedify-dev/fedify/pull/927 - '#928': https://github.com/fedify-dev/fedify/pull/928 ---- - - Added [FEP-ef61] vocabulary terms for portable ActivityPub objects. - Actor classes now expose ordered `gateways` lists, and `Link` plus - document/media classes expose `digestMultibase` for external resource - integrity metadata. - [[#830], [#928]] - - Updated [FEP-fe34] cross-origin checks to understand cryptographic origins - for [FEP-ef61] portable ActivityPub IDs and DID URLs. Generated property - accessors and `lookupObject()` now treat `ap:`/`ap+ef61:` IDs and matching - `did:key` verification method IDs as same-origin when their DID components - match. - [[#829], [#926]] - - Added support for [FEP-ef61] portable ActivityPub IRIs in generated - vocabulary codecs. `ap:` and `ap+ef61:` values with decoded or - percent-encoded DID authorities now parse as `URL` objects, and JSON-LD - serialization emits canonical `ap+ef61:` values with decoded DID - authorities. - [[#826], [#850]] - - Added vocabulary support for [FEP-7aa9], including - `FeaturedCollection`, `FeaturedItem`, `FeatureRequest`, and - `FeatureAuthorization`, plus actor `featuredCollections` and - `InteractionPolicy.canFeature` properties. - [[#810], [#914]] - - Added the `Endpoints.uploadMedia` property, the standard ActivityStreams - endpoint for the [ActivityPub Media Upload extension]. - [[#754], [#927]] - - Fixed the CommonJS vocabulary build so it no longer requires - `@js-temporal/polyfill` at runtime. The build now bundles - `temporal-polyfill`, while type declarations rely on the standard - `esnext.temporal` lib reference. - [[#823], [#925]] - -[FEP-ef61]: https://w3id.org/fep/ef61 -[FEP-fe34]: https://w3id.org/fep/fe34 -[FEP-7aa9]: https://w3id.org/fep/7aa9 -[ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload diff --git a/changes.d/vocab/lookup-object-gateways.md b/changes.d/vocab/lookup-object-gateways.md deleted file mode 100644 index d12b99d0f..000000000 --- a/changes.d/vocab/lookup-object-gateways.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -links: - '#1158': https://github.com/fedify-dev/fedify/issues/1158 - '#1194': https://github.com/fedify-dev/fedify/pull/1194 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added a `gateways` option to `lookupObject()` and - `Context.lookupObject()`, so applications can look up a bare - [FEP-ef61] portable object ID without constructing location hints or - providing a custom document loader. The listed gateways are tried in - order in place of any `@gateway` hints. [[#288], [#1158], [#1194]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/portable-chain-verifiers.md b/changes.d/vocab/portable-chain-verifiers.md deleted file mode 100644 index 205989c11..000000000 --- a/changes.d/vocab/portable-chain-verifiers.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -links: - '#1107': https://github.com/fedify-dev/fedify/issues/1107 - '#1120': https://github.com/fedify-dev/fedify/pull/1120 - '#1129': https://github.com/fedify-dev/fedify/issues/1129 - '#1137': https://github.com/fedify-dev/fedify/pull/1137 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Property accessors of objects whose IDs are [FEP-ef61] compatible - identifiers, e.g., - `https://gw.example/.well-known/apgateway/did:key:z6Mk.../activities/1`, - now dereference their references as portable objects, as those of objects - with `ap:` IDs do. Previously, such an accessor fetched a compatible - identifier as an ordinary HTTP(S) URL and trusted the result because of - its origin, so anyone could serve an unsigned object at the reference. - Without the `verifyPortableObject` option, such an accessor now throws - a `TypeError` (or returns `null` with `suppressError: true`) for - a portable reference or a compatible identifier; pass a `Context` from - `@fedify/fedify`, which carries a verifier, or use a default verifier - (see below). An object whose ID is a malformed compatible identifier is - handled the same way. Having such an ID does not make an object - verified. [[#288], [#1107], [#1120]] - - - Added the `verifyPortableObject` option to the constructors, the - `fromJsonLd()` methods, and the `clone()` methods of vocabulary - classes. The object uses it by default when its property accessors are - called without the option. Objects embedded in the JSON-LD document it - was parsed from inherit it. `lookupObject()` passes its - `verifyPortableObject` option to the object it returns. - [[#288], [#1107], [#1120]] - - - Objects that property accessors and `traverseCollection()` fetch now use - the `verifyPortableObject` option of that call, or else the default of - the object they were obtained from, as their default verifier. So - `(await create.getObject(ctx))?.getAttribution()` verifies portable - objects without passing `ctx` again. Objects already cached in their - parent keep the default they got when they were fetched. Having - a default verifier does not mean that an object was verified. - [[#288], [#1129], [#1137]] - - - Added the `inheritPortableObjectVerifier` option to property accessors - and `TraverseCollectionOptions`. Set it to `false` to keep - a `verifyPortableObject` option to that call only, e.g., for a verifier - that accepts everything; fetched objects then use the default of the - object they were obtained from, if any. [[#288], [#1129], [#1137]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/portable-collection-trust.md b/changes.d/vocab/portable-collection-trust.md deleted file mode 100644 index 1c07c9595..000000000 --- a/changes.d/vocab/portable-collection-trust.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -links: - '#1084': https://github.com/fedify-dev/fedify/pull/1084 - '#1090': https://github.com/fedify-dev/fedify/issues/1090 - '#1091': https://github.com/fedify-dev/fedify/pull/1091 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#836': https://github.com/fedify-dev/fedify/issues/836 ---- - - Property accessors now tell the `verifyPortableObject` function where - a portable object was retrieved from and which objects led to it, so - that it can apply the [FEP-ef61] trust policy for portable collections - served without proofs. Accessors of such an unsecured collection do not - trust the objects embedded in it, even with `crossOrigin: "trust"`; - they fetch and verify each of them on its own, and drop the ones without - an `@id`. [[#288], [#836], [#1084], [#1090], [#1091]] - - - Added `crossOrigin`, `gateways`, and `verifyPortableObject` options to - `TraverseCollectionOptions`, so that `traverseCollection()` can traverse - [FEP-ef61] portable collections through gateways. - [[#288], [#836], [#1084], [#1090], [#1091]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/portable-gateway-dereferencing.md b/changes.d/vocab/portable-gateway-dereferencing.md deleted file mode 100644 index caf1227dc..000000000 --- a/changes.d/vocab/portable-gateway-dereferencing.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -links: - '#1077': https://github.com/fedify-dev/fedify/pull/1077 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#834': https://github.com/fedify-dev/fedify/issues/834 ---- - - Added [FEP-ef61] gateway dereferencing to property accessors such as - `Create.getObject()`. Accessors now fetch `ap:` and `ap+ef61:` - references through the gateways given by the new `gateways` option, or - through the `@gateway` location hints in the reference when the option is - omitted, trying each gateway in order until one serves a valid object. - A fetched object is returned only if its `@id` canonically matches the - reference and the new `verifyPortableObject` option, typically - `verifyPortableObjectProof()` from `@fedify/fedify`, accepts it. Portable - references can no longer be dereferenced without `verifyPortableObject`, - and the `crossOrigin: "trust"` option does not skip these checks. - HTTP(S) references are fetched as before. [[#288], [#834], [#1077]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/portable-inspection.md b/changes.d/vocab/portable-inspection.md deleted file mode 100644 index 7e5c7a053..000000000 --- a/changes.d/vocab/portable-inspection.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -links: - '#1156': https://github.com/fedify-dev/fedify/issues/1156 - '#1199': https://github.com/fedify-dev/fedify/pull/1199 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed the inspection of vocabulary objects, e.g., with `console.log()`, - `util.inspect()`, or `Deno.inspect()`, to show [FEP-ef61] portable IDs - in their canonical form, e.g., `ap+ef61://did:key:z6Mk.../actor`, instead - of the percent-encoded form of the `URL` objects that represent them, - e.g., `ap+ef61://did%3Akey%3Az6Mk.../actor`. [[#288], [#1156], [#1199]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/portable-verifier-types.md b/changes.d/vocab/portable-verifier-types.md deleted file mode 100644 index 999531d5d..000000000 --- a/changes.d/vocab/portable-verifier-types.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -links: - '#1160': https://github.com/fedify-dev/fedify/issues/1160 - '#1184': https://github.com/fedify-dev/fedify/pull/1184 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Exported portable object verifier types from `@fedify/vocab`, so callers - can type custom verifiers without importing `@fedify/vocab-runtime`. - Also exported other types used in vocabulary API signatures. - [[#288], [#1160], [#1184]] diff --git a/changes.d/vocab/portable-webfinger-lookup.md b/changes.d/vocab/portable-webfinger-lookup.md deleted file mode 100644 index 1131aa71f..000000000 --- a/changes.d/vocab/portable-webfinger-lookup.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -links: - '#1082': https://github.com/fedify-dev/fedify/pull/1082 - '#288': https://github.com/fedify-dev/fedify/issues/288 - '#837': https://github.com/fedify-dev/fedify/issues/837 ---- - - Added the `verifyPortableObject` option to `LookupObjectOptions`. With - it, `lookupObject()` looks up [FEP-ef61] portable objects: `ap:` and - `ap+ef61:` URIs through their `@gateway` location hints, compatible - identifiers through the gateways they name, and portable actors' - handles through the `self` links of their WebFinger responses, which - can be either of them. A fetched object is returned only if its `@id` - identifies the requested portable object and the option accepts it; - otherwise, the next candidate is tried, at most five gateways per - lookup. `crossOrigin: "throw"` makes a rejected object throw an error, - and `crossOrigin: "trust"` does not skip the checks. - [[#288], [#837], [#1082]] - - - `crossOrigin: "trust"` no longer makes `lookupObject()` return an object - whose `@id` is an `ap:` or `ap+ef61:` URI from a document URL of another - origin, such as a compatible identifier fetched as an ordinary HTTP(S) - URL, since a portable object is authenticated by its proofs, not by the - server that serves it. Look such objects up with the - `verifyPortableObject` option instead. [[#288], [#837], [#1082]] - - - `getActorHandle()` now supports [FEP-ef61] portable actors. It takes the - domain of a portable actor's handle from the first gateway in its - `gateways` instead of its ID, and returns the handle only if its - WebFinger response links back to the actor, either by its portable ID - or by a compatible identifier, since the gateways are claimed by the - actor itself. It throws a `TypeError` otherwise, including when the - WebFinger lookup fails, and for a portable actor without `gateways` or - `preferredUsername` or a portable actor URI. [[#288], [#837], [#1082]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/recipient-gateways.md b/changes.d/vocab/recipient-gateways.md deleted file mode 100644 index daf8db6ab..000000000 --- a/changes.d/vocab/recipient-gateways.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -links: - '#1147': https://github.com/fedify-dev/fedify/issues/1147 - '#1180': https://github.com/fedify-dev/fedify/pull/1180 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Added the optional `Recipient.gateways` property, the [FEP-ef61] gateways - of a portable actor, through which Fedify delivers activities to its - `ap:` or `ap+ef61:` inbox. Every actor class already has it, so - applications that build `Recipient` objects by hand, e.g., in followers - collection dispatchers, need to set it only for portable actors. - [[#288], [#1147], [#1180]] - -[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/changes.d/vocab/signed-child-representation.md b/changes.d/vocab/signed-child-representation.md deleted file mode 100644 index 3510c0c56..000000000 --- a/changes.d/vocab/signed-child-representation.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -links: - '#1044': https://github.com/fedify-dev/fedify/issues/1044 - '#1051': https://github.com/fedify-dev/fedify/pull/1051 - '#288': https://github.com/fedify-dev/fedify/issues/288 ---- - - Changed nested serialization so that an object carrying a signed JSON-LD - representation retained by `signObject()` is embedded with that exact - representation, including its own `@context`, rather than being rebuilt - under the parent's context. `clone()` never carries the retained - representation, because a clone may differ from the document the proof - covers. [[#288], [#1044], [#1051]] diff --git a/changes.d/vocab/translation-metadata.md b/changes.d/vocab/translation-metadata.md deleted file mode 100644 index 7b7bcca0a..000000000 --- a/changes.d/vocab/translation-metadata.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -links: - '#1037': https://github.com/fedify-dev/fedify/issues/1037 - '#1038': https://github.com/fedify-dev/fedify/pull/1038 ---- - - Added vocabulary support for the [FEP-22cd] draft, associating each - translated version with its translators, source object, and optional source - review timestamp. [[#1037], [#1038]] - - - Added `Translation` class with `id`, `language`, `original`, - `sourceUpdated`, `basis`, `url`, and `urls` properties. - - Added `Translation.getTranslator()`/`Translation.translatorId` and - `Translation.getTranslators()`/`Translation.translatorIds` for - accessing credited actors. The constructor accepts `translator` - and `translators` values. - - Added `Object.translations` property, inherited by `Article`, - `Note`, and other object types, for per-language translation - metadata without creating separate posts. - -[FEP-22cd]: https://w3id.org/fep/22cd diff --git a/changes.d/webfinger/lookup-diagnostics.md b/changes.d/webfinger/lookup-diagnostics.md new file mode 100644 index 000000000..ffa99bd50 --- /dev/null +++ b/changes.d/webfinger/lookup-diagnostics.md @@ -0,0 +1,11 @@ +--- +links: + '#1296': https://github.com/fedify-dev/fedify/pull/1296 + '#897': https://github.com/fedify-dev/fedify/issues/897 +--- + - Added a `withDiagnostics` option to `lookupWebFinger()`. When turned on, + the function returns an object describing why the lookup failed—a + network error, a malformed response, an unexpected server error, or the + resource genuinely not existing—instead of returning `null` for every + failure. `Context.lookupWebFinger()` does not support this option. + [[#897], [#1296] by Jae-Hyuk-Jang] diff --git a/deno.json b/deno.json index f2a3c4469..50a4847b2 100644 --- a/deno.json +++ b/deno.json @@ -53,6 +53,8 @@ "@logtape/file": "jsr:@logtape/file@^2.2.0", "@logtape/logtape": "jsr:@logtape/logtape@^2.2.0", "@nestjs/common": "npm:@nestjs/common@^11.0.1", + "@nestjs/core": "npm:@nestjs/core@11.1.4", + "@nestjs/platform-express": "npm:@nestjs/platform-express@11.1.4", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.1", "@opentelemetry/context-async-hooks": "npm:@opentelemetry/context-async-hooks@^2.7.1", "@opentelemetry/core": "npm:@opentelemetry/core@^2.7.1", @@ -91,6 +93,7 @@ "mysql2/promise": "npm:mysql2@^3.22.3/promise", "postgres": "npm:postgres@^3.4.7", "preact": "npm:preact@10.19.6", + "reflect-metadata": "npm:reflect-metadata@^0.2.2", "temporal-polyfill": "npm:temporal-polyfill@^1.0.1", "tsdown": "npm:tsdown@^0.22.0", "typescript": "npm:typescript@^6.0.0" @@ -135,7 +138,9 @@ ], "exclude": [ "./packages/adonisjs/**", + "./packages/elysia/src/fallback.test.ts", "./packages/init/src/templates/**", + "./packages/nestjs/src/**/*.test.ts", "./packages/next/**" ] } diff --git a/deno.lock b/deno.lock index 371a9f786..0d910c4a9 100644 --- a/deno.lock +++ b/deno.lock @@ -92,6 +92,8 @@ "npm:@jsr/std__assert@0.226": "0.226.0", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@nestjs/common@^11.0.1": "11.1.19_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/core@11.1.4": "11.1.4_@nestjs+common@11.1.19__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+platform-express@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/platform-express@11.1.4": "11.1.4_@nestjs+common@11.1.19__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+core@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@netlify/async-workloads@^0.0.106": "0.0.106_@google-cloud+storage@5.20.5_@trpc+server@11.18.0__typescript@5.9.3_@types+react@19.2.17_autoprefixer@10.5.2__postcss@8.5.19_graphql@16.14.2_postcss@8.5.19_react@19.2.7_react-dom@19.2.7__react@19.2.7_rollup@4.60.1_tailwindcss@3.4.19__tsx@4.21.0__yaml@2.9.0_tsx@4.21.0_yaml@2.9.0", "npm:@netlify/blobs@^11.0.3": "11.0.3", "npm:@nuxt/kit@4": "4.4.2", @@ -174,7 +176,9 @@ "npm:preact@^10.27.2": "10.29.2", "npm:preact@^10.28.2": "10.29.2", "npm:preact@^10.29.1": "10.29.2", + "npm:reflect-metadata@~0.2.2": "0.2.2", "npm:rollup@^4.55.1": "4.60.1", + "npm:semver@^7.7.2": "7.8.5", "npm:sharp@0.33.5": "0.33.5", "npm:shiki@^1.6.4": "1.29.2", "npm:smol-toml@^1.6.1": "1.6.1", @@ -3625,6 +3629,37 @@ "uid" ] }, + "@nestjs/core@11.1.4_@nestjs+common@11.1.19__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+platform-express@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-pTLjlfLCSgEvCm8iOONLtHN9uw4XyMEI4yec6+jdw6/7QuOHoqEPAVvdUlNwW1OfFjrAdZmZlpf6Uw8pWBfQlQ==", + "dependencies": [ + "@nestjs/common", + "@nestjs/platform-express", + "@nuxt/opencollective", + "fast-safe-stringify", + "iterare", + "path-to-regexp@8.2.0", + "reflect-metadata", + "rxjs@7.8.2", + "tslib@2.8.1", + "uid" + ], + "optionalPeers": [ + "@nestjs/platform-express" + ], + "scripts": true + }, + "@nestjs/platform-express@11.1.4_@nestjs+common@11.1.19__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+core@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-SDpfSDJogCZ3fOeP518+GdjKnpJc51LN0dylapq3xBfxJeiEOqmqiZM1tWMOsoijPlUVsz0acpmaVEaOiMXpXw==", + "dependencies": [ + "@nestjs/common", + "@nestjs/core", + "cors", + "express@5.1.0", + "multer", + "path-to-regexp@8.2.0", + "tslib@2.8.1" + ] + }, "@netlify/async-workloads@0.0.106_@google-cloud+storage@5.20.5_@trpc+server@11.18.0__typescript@5.9.3_@types+react@19.2.17_autoprefixer@10.5.2__postcss@8.5.19_graphql@16.14.2_postcss@8.5.19_react@19.2.7_react-dom@19.2.7__react@19.2.7_rollup@4.60.1_tailwindcss@3.4.19__tsx@4.21.0__yaml@2.9.0_tsx@4.21.0_yaml@2.9.0": { "integrity": "sha512-G5FBVc8qIHKgg3F4VGVBfd4IUHyIo6+0Ojx/f2RoAwsqgtMRmi3hPK597i4NxnOu67aCNzyXmibLkyqpcNiIoA==", "dependencies": [ @@ -3712,7 +3747,7 @@ "devcert", "enhanced-resolve", "execa@5.1.1", - "express", + "express@4.22.1", "fastest-levenshtein", "fastq", "file-type@16.5.4", @@ -3809,7 +3844,7 @@ "devcert", "enhanced-resolve", "execa@5.1.1", - "express", + "express@4.22.1", "fastest-levenshtein", "fastq", "file-type@16.5.4", @@ -4453,6 +4488,13 @@ "untyped" ] }, + "@nuxt/opencollective@0.4.1": { + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "dependencies": [ + "consola" + ], + "bin": true + }, "@nuxt/schema@4.4.2": { "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", "dependencies": [ @@ -5627,7 +5669,7 @@ "chalk@4.1.2", "configstore@5.0.1", "detect-port", - "express", + "express@4.22.1", "file-type@16.5.4", "fs-extra@10.1.0", "git-url-parse", @@ -5656,7 +5698,7 @@ "cors", "detect-port", "dotenv@16.6.1", - "express", + "express@4.22.1", "fs-extra@10.1.0", "http-proxy-middleware", "lodash", @@ -6616,6 +6658,13 @@ "negotiator@0.6.3" ] }, + "accepts@2.0.0": { + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": [ + "mime-types@3.0.2", + "negotiator@1.1.0" + ] + }, "acorn-globals@7.0.1": { "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "dependencies": [ @@ -6769,6 +6818,9 @@ "picomatch@2.3.2" ] }, + "append-field@1.0.0": { + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "application-config-path@0.1.1": { "integrity": "sha512-zy9cHePtMP0YhwG+CfHm0bgwdnga2X3gZexpdCwEj//dpb+TKajtiC8REEUJUSq6Ab4f9cgNy2l8ObXzCXFkEw==" }, @@ -7201,19 +7253,33 @@ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "dependencies": [ "bytes", - "content-type", + "content-type@1.0.5", "debug@2.6.9", "depd@2.0.0", "destroy", "http-errors@2.0.1", "iconv-lite@0.4.24", "on-finished", - "qs", - "raw-body", - "type-is", + "qs@6.14.2", + "raw-body@2.5.3", + "type-is@1.6.18", "unpipe" ] }, + "body-parser@2.3.0": { + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": [ + "bytes", + "content-type@2.1.0", + "debug@4.4.3", + "http-errors@2.0.1", + "iconv-lite@0.7.2", + "on-finished", + "qs@6.16.0", + "raw-body@3.0.2", + "type-is@2.1.0" + ] + }, "boolbase@1.0.0": { "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, @@ -7316,6 +7382,12 @@ "run-applescript@7.1.0" ] }, + "busboy@1.6.0": { + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": [ + "streamsearch" + ] + }, "byte-encodings@1.0.11": { "integrity": "sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg==" }, @@ -7764,6 +7836,15 @@ "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "concat-stream@2.0.0": { + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dependencies": [ + "buffer-from", + "inherits", + "readable-stream@3.6.2", + "typedarray" + ] + }, "confbox@0.1.8": { "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" }, @@ -7800,6 +7881,9 @@ "safe-buffer@5.2.1" ] }, + "content-disposition@1.1.0": { + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, "content-engine-graphiql-explorer@0.0.1": { "integrity": "sha512-ZeWPqWrwkap3VyyYGRyNLiIzslgNAgDVd1LTX5Jv3IxpL3FPaJhV05/8LK7ONdp9LJpWWhnmdQOU8iq9NKurXA==" }, @@ -7828,7 +7912,7 @@ "devcert", "enhanced-resolve", "execa@5.1.1", - "express", + "express@4.22.1", "fastest-levenshtein", "fastq", "file-type@16.5.4", @@ -7882,6 +7966,9 @@ "content-type@1.0.5": { "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, + "content-type@2.1.0": { + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==" + }, "contentful-management@7.54.2": { "integrity": "sha512-up7a31QsVM3ADXm6CrLGp9u/AVHO/yIDJGyS9Ztesa/xlxJgGmrEMTqUevauT1Y/5vC54gBM5S8in1zcP7atqQ==", "dependencies": [ @@ -7906,7 +7993,7 @@ "lodash.isplainobject", "lodash.isstring", "p-throttle", - "qs" + "qs@6.16.0" ] }, "contentful-sdk-core@8.3.2": { @@ -7916,7 +8003,7 @@ "lodash.isplainobject", "lodash.isstring", "p-throttle", - "qs" + "qs@6.16.0" ] }, "contentful@9.3.7": { @@ -7952,6 +8039,9 @@ "cookie-signature@1.0.7": { "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "cookie-signature@1.2.2": { + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, @@ -7978,8 +8068,8 @@ "core-util-is@1.0.3": { "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, - "cors@2.8.6": { - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "cors@2.8.5": { + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "dependencies": [ "object-assign", "vary" @@ -8686,7 +8776,7 @@ "@types/cors", "@types/node@24.12.2", "@types/ws", - "accepts", + "accepts@1.3.8", "base64id", "cookie@0.7.2", "cors", @@ -9483,39 +9573,71 @@ "express@4.22.1": { "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dependencies": [ - "accepts", + "accepts@1.3.8", "array-flatten", - "body-parser", - "content-disposition", - "content-type", + "body-parser@1.20.4", + "content-disposition@0.5.4", + "content-type@1.0.5", "cookie@0.7.2", - "cookie-signature", + "cookie-signature@1.0.7", "debug@2.6.9", "depd@2.0.0", "encodeurl@2.0.0", "escape-html", "etag", - "finalhandler", + "finalhandler@1.3.2", "fresh@0.5.2", "http-errors@2.0.1", - "merge-descriptors", + "merge-descriptors@1.0.3", "methods", "on-finished", "parseurl", "path-to-regexp@0.1.13", "proxy-addr", - "qs", + "qs@6.14.2", "range-parser", "safe-buffer@5.2.1", "send@0.19.2", "serve-static@1.16.3", "setprototypeof", "statuses@2.0.2", - "type-is", + "type-is@1.6.18", "utils-merge", "vary" ] }, + "express@5.1.0": { + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": [ + "accepts@2.0.0", + "body-parser@2.3.0", + "content-disposition@1.1.0", + "content-type@1.0.5", + "cookie@0.7.2", + "cookie-signature@1.2.2", + "debug@4.4.3", + "encodeurl@2.0.0", + "escape-html", + "etag", + "finalhandler@2.1.1", + "fresh@2.0.0", + "http-errors@2.0.1", + "merge-descriptors@2.0.0", + "mime-types@3.0.2", + "on-finished", + "once", + "parseurl", + "proxy-addr", + "qs@6.16.0", + "range-parser", + "router", + "send@1.2.1", + "serve-static@2.2.1", + "statuses@2.0.2", + "type-is@2.1.0", + "vary" + ] + }, "exsolve@1.0.8": { "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" }, @@ -9605,6 +9727,9 @@ "fast-decode-uri-component" ] }, + "fast-safe-stringify@2.1.1": { + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "fast-string-truncated-width@3.0.3": { "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" }, @@ -9772,6 +9897,17 @@ "unpipe" ] }, + "finalhandler@2.1.1": { + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": [ + "debug@4.4.3", + "encodeurl@2.0.0", + "escape-html", + "on-finished", + "parseurl", + "statuses@2.0.2" + ] + }, "find-my-way@9.5.0": { "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", "dependencies": [ @@ -11507,10 +11643,10 @@ "koa@2.16.4": { "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", "dependencies": [ - "accepts", + "accepts@1.3.8", "cache-content-type", - "content-disposition", - "content-type", + "content-disposition@0.5.4", + "content-type@1.0.5", "cookies", "debug@4.4.3", "delegates", @@ -11528,7 +11664,7 @@ "only", "parseurl", "statuses@1.5.0", - "type-is", + "type-is@1.6.18", "vary" ] }, @@ -12066,6 +12202,9 @@ "media-typer@0.3.0": { "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "media-typer@1.1.1": { + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==" + }, "memoirist@0.4.0": { "integrity": "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==" }, @@ -12094,6 +12233,9 @@ "merge-descriptors@1.0.3": { "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, + "merge-descriptors@2.0.0": { + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, "merge-options@3.0.4": { "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", "dependencies": [ @@ -12471,6 +12613,18 @@ "msgpackr-extract" ] }, + "multer@2.0.1": { + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "dependencies": [ + "append-field", + "busboy", + "concat-stream", + "mkdirp", + "object-assign", + "type-is@1.6.18", + "xtend" + ] + }, "mute-stream@0.0.8": { "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, @@ -12527,6 +12681,12 @@ "negotiator@0.6.4": { "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==" }, + "negotiator@1.1.0": { + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dependencies": [ + "content-type@2.1.0" + ] + }, "neotraverse@0.6.18": { "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==" }, @@ -12538,7 +12698,7 @@ "micro-api-client", "node-fetch@3.3.2", "p-wait-for", - "qs" + "qs@6.16.0" ] }, "next-tick@1.1.0": { @@ -13220,6 +13380,9 @@ "path-to-regexp@6.3.0": { "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" }, + "path-to-regexp@8.2.0": { + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" + }, "path-type@4.0.0": { "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, @@ -13597,6 +13760,13 @@ "side-channel" ] }, + "qs@6.16.0": { + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dependencies": [ + "es-define-property", + "side-channel" + ] + }, "quansync@0.2.11": { "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==" }, @@ -13633,6 +13803,15 @@ "unpipe" ] }, + "raw-body@3.0.2": { + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": [ + "bytes", + "http-errors@2.0.1", + "iconv-lite@0.7.2", + "unpipe" + ] + }, "rc9@3.0.1": { "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", "dependencies": [ @@ -14114,6 +14293,16 @@ ], "bin": true }, + "router@2.2.0": { + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": [ + "debug@4.4.3", + "depd@2.0.0", + "is-promise@4.0.0", + "parseurl", + "path-to-regexp@8.2.0" + ] + }, "run-applescript@5.0.0": { "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", "dependencies": [ @@ -14499,8 +14688,8 @@ "side-channel-map" ] }, - "side-channel@1.1.0": { - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "side-channel@1.1.1": { + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dependencies": [ "es-errors", "object-inspect", @@ -14587,7 +14776,7 @@ "socket.io@4.8.3": { "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", "dependencies": [ - "accepts", + "accepts@1.3.8", "base64id", "cors", "debug@4.4.3", @@ -14738,6 +14927,9 @@ "stream-shift@1.0.3": { "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" }, + "streamsearch@1.1.0": { + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + }, "streamx@2.25.0": { "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "dependencies": [ @@ -15423,10 +15615,18 @@ "type-is@1.6.18": { "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dependencies": [ - "media-typer", + "media-typer@0.3.0", "mime-types@2.1.35" ] }, + "type-is@2.1.0": { + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": [ + "content-type@2.1.0", + "media-typer@1.1.1", + "mime-types@3.0.2" + ] + }, "type-of@2.0.1": { "integrity": "sha512-39wxbwHdQ2sTiBB8wAzKfQ9GN+om8w+sjNWzr+vZJR5AMD5J+J7Yc8AtXnU9r/r2c8XiDZ/smxutDmZehX/qpQ==" }, @@ -15480,6 +15680,9 @@ "is-typedarray" ] }, + "typedarray@0.0.6": { + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "typescript@5.9.3": { "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "bin": true @@ -16569,6 +16772,8 @@ "npm:@fxts/core@^1.21.1", "npm:@js-temporal/polyfill@~0.5.1", "npm:@nestjs/common@^11.0.1", + "npm:@nestjs/core@11.1.4", + "npm:@nestjs/platform-express@11.1.4", "npm:@opentelemetry/api@^1.9.1", "npm:@opentelemetry/context-async-hooks@^2.7.1", "npm:@opentelemetry/core@^2.7.1", @@ -16592,6 +16797,7 @@ "npm:mysql2@^3.22.3", "npm:postgres@^3.4.7", "npm:preact@10.19.6", + "npm:reflect-metadata@~0.2.2", "npm:temporal-polyfill@^1.0.1", "npm:tsdown@0.22", "npm:typescript@6" @@ -16649,6 +16855,7 @@ "packageJson": { "dependencies": [ "npm:@cloudflare/vitest-pool-workers@~0.8.31", + "npm:semver@^7.7.2", "npm:vitest@3.2", "npm:wrangler@^4.21.1" ] @@ -16812,6 +17019,11 @@ "jsr:@std/assert@^1.0.13" ] }, + "packages/solidstart": { + "dependencies": [ + "npm:h3@^1.15.0" + ] + }, "packages/sqlite": { "packageJson": { "dependencies": [ diff --git a/docs/manual/collections.md b/docs/manual/collections.md index 0924bb612..2a9a3d249 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -1758,6 +1758,10 @@ section](./inbox.md#portable-inboxes)). The served collection has the same ID as the actor's property, whether it is a portable ID or a compatible identifier, and its `attributedTo` is the actor. +A collection that is not paginated always has `totalItems`, which is the number +of items the dispatcher returns if no counter is set, so that consumers can +tell an empty collection, which has no other collection properties, from other +objects, as [FEP-2277] does. Its pages are identified by the collection's ID with the `cursor` query parameter, e.g., `ap+ef61://did:key:z6Mk.../users/alice/outbox?cursor=abc`, and their `first`, `last`, `prev`, `next`, and `partOf` stay under the same @@ -1822,3 +1826,4 @@ federation > custom portable collections may not be usable by others yet. [FEP-ef61]: https://w3id.org/fep/ef61 +[FEP-2277]: https://w3id.org/fep/2277 diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index ab88c0bcf..4a3df6004 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -187,6 +187,86 @@ authenticated by their [Linked Data Signatures] or Object Integrity Proofs do not rely on the request's signatures, so they are not affected. +Observing inbox requests +------------------------ + +*This API is available since Fedify 2.4.0.* + +Register `onRequestFinished()` to record the result of each inbox delivery, +including rejected requests. The callback receives a `RequestContext` and an +`InboxRequestReport`. It runs once after processing and is awaited before +`Federation.fetch()` returns a response or rethrows an exception: + +~~~~ typescript twoslash +import type { Federation, InboxRequestReport } from "@fedify/fedify"; +declare const federation: Federation; +declare function saveReport(report: InboxRequestReport): Promise; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .onRequestFinished(async (ctx, report) => { + await saveReport(report); + }); +~~~~ + +`report.inbox` identifies the personal, shared, or portable inbox and its local +recipient identifier. Shared inboxes have a `null` recipient. The hook also +covers failures while preparing document loaders or resolving a portable +recipient. Inbox collection requests, unmatched routes, programmatic +`routeActivity()` calls, and queue workers do not invoke it. + +`report.payload` is either `unavailable` or `parsed`, whose `value` contains the +original JSON. A parsed JSON `null` is distinct from an unavailable body. +`report.activity` contains the `Activity` obtained by the existing processing +flow, if any. Observation does not parse the body again or fetch more objects. + +`report.attempts` retains each logical evaluation of HTTP Signatures, Linked +Data Signatures, or Object Integrity Proofs. An attempt's `checks` describes +the signatures/proofs evaluated, including the declared key ID and the actual +`CryptoKey` objects used. A stale cached key and its fresh replacement are +both retained in `triedKeys`. Refresh failure retains the key already tried. +A verified check's `key` is the successful entry in `triedKeys`, and a verified +attempt's `signatures` references its successful checks directly. Attempts +can repeat a mechanism when additional portable proof policy is evaluated. +The subject includes its ID and an RFC 6901 JSON Pointer when known; the root +pointer is `""`. + +Cryptographic checks and authentication are separate. For example, valid +proofs can leave an actor attribution uncovered, or a valid HTTP Signature can +fail the actor ownership or nonce check. Such checks remain `verified` in a +rejected attempt or request. Linked Data Signature failure followed by HTTP +success retains both attempts. Consult `report.authentication` for the final +`verified`, `rejected`, `skipped`, or `notDetermined` decision. `skipped` means +processing reached the signature bypass; an earlier parse or preparation +failure leaves authentication `notDetermined`. + +Key snapshots have `URL | null` IDs and either `ownerId` for a +`cryptographicKey` or `controllerId` for a `multikey`. These URL objects are +independent of the vocabulary key objects. The declared key ID is a +`string | null`, preserving its spelling even when invalid. Keys and their +owner/controller claims remain untrusted until the final authentication +checks accept them. A readonly report does not freeze its `Activity`, +`CryptoKey`, or error objects. + +`report.outcome` records a response status and disposition (`processed`, +`enqueued`, `duplicate`, `unhandled`, `rejected`, `customResponse`, or +`failed`), or the original exception and its processing stage. An `enqueued` +result reports producer acceptance, not later worker success. A custom `202` +returned by `onUnverifiedActivity()` remains unauthenticated. The report +contains no live `Response` to consume or alter. + +Errors from the observer are logged and swallowed, preserving the delivery's +original response or exception. The callback does not make database writes +and queue acceptance atomic. Calling `onRequestFinished()` again replaces the +previous callback; a federation built from a builder retains the callback +registered when it was built. Reports and key objects are never serialized +into queue messages. + +The hook runs independently of [OpenTelemetry sampling](./opentelemetry.md). +Use it when your application needs every delivery's result or actual public +keys for later inspection. + + Handling unverified activities ------------------------------ @@ -208,6 +288,15 @@ For these cases, you can register `RequestContext`, the parsed activity, and a reason object whose `type` is one of `"noSignature"`, `"invalidSignature"`, or `"keyFetchError"`. +For a `"keyFetchError"` without an HTTP response, `reason.result.error` +contains the failure. The built-in key cache preserves `FetchError` and +`UrlError` instances, including `FetchError.url` and `UrlError.reason`, so a +cached failure can still be classified without parsing its message. It also +preserves the immediate cause's name and message, restoring a `DOMException` +cause as a `DOMException`. Entries written by older Fedify versions and +unknown error classes remain generic `Error` instances with their original +name and message. + If the callback returns a `Response`, Fedify uses it as-is. If it returns nothing (`void`), Fedify falls back to the default `401 Unauthorized` response. @@ -313,6 +402,121 @@ multiple inbox listeners for different activity types. [shared inbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery +Authenticated principals +------------------------ + +*This API is available since Fedify 2.5.0.* + +Before an activity reaches an inbox listener, Fedify authenticates it with HTTP +Signatures, Linked Data Signatures, or Object Integrity Proofs. The parties it +authenticated, called *principals*, are available to the listener as +`InboxContext.authentication`, independently of the activity's declared +`actor`: + +`requestSigners` +: The actors whose keys signed the HTTP request, i.e., the server that + delivered the activity, which may have forwarded it on behalf of + another actor. It is empty when the activity document was authenticated + by its own signatures, so that the HTTP Signatures did not need to be + verified. + +`documentSigners` +: The actors whose keys made the verified Linked Data Signature or Object + Integrity Proofs of the activity document. When there are several + proofs, all of their signers are listed. A proof by the DID of an + [FEP-ef61] portable actor is listed as a `portableAuthority` principal, + which authenticates the portable actors of that DID. + +`originAuthenticated` +: Only for activities routed by `Context.routeActivity()`: the actors + authenticated because the activity was fetched again from their own web + origin. + +`expandedActivity` +: The activity as Fedify interpreted it while authenticating it, in expanded + JSON-LD. Typed vocabulary objects omit some values, such as blank node + references and values they cannot decode, so check this when your + authorization has to count every value of a property. + +Use `isActorAuthenticated()` to check whether a specific actor was +authenticated. It considers the document signers if there are any, otherwise +the request signers, and compares [FEP-ef61] portable actor IDs canonically: + +~~~~ typescript twoslash +import { type Federation, isActorAuthenticated } from "@fedify/fedify"; +import { Undo } from "@fedify/vocab"; +declare const federation: Federation; +declare function undoFollow(follower: URL, follow: URL): Promise; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Undo, async (ctx, undo) => { + if (undo.actorId == null || undo.objectId == null) return; + // Always true for an activity that declares an actor, which Fedify + // authenticates before calling the listener: + if (!isActorAuthenticated(ctx.authentication, undo.actorId)) return; + await undoFollow(undo.actorId, undo.objectId); + }); +~~~~ + +A valid signature alone does not authorize anything: whoever signed an activity +can name anyone else's objects in it, so compare the authenticated principal +with the owner of what the activity acts on. The evidence, including the +interpretation of the activity, is kept as it was when the activity was +received, even when the activity is processed later by a [message +queue](./mq.md) and its retries. + +### Activities without an actor + +Some protocols identify the requester without an `actor`. For example, +Mastodon sends [FEP-7aa9] `FeatureRequest` activities, which ask an actor for +consent to be featured in a collection, without one. By default, Fedify +responds to such an activity with `400 Bad Request` before any listener runs. + +To receive activities without an actor, register the listener with the +`allowActorless` option. Such an activity then reaches the listener only if +Fedify authenticated at least one principal for it, and its `id`, if any, has +the same origin as that principal. The listener must authorize the request +itself. For `FeatureRequest`, `featureInteraction.verifyRequest()` from +[`@fedify/interaction-controls`](./interaction-controls.md) does that: it +fetches the featured collection from its ID and checks that its owner is the +authenticated requester: + +~~~~ typescript twoslash +import type { Federation } from "@fedify/fedify"; +import { featureInteraction } from "@fedify/interaction-controls"; +import { FeatureRequest } from "@fedify/vocab"; +declare const federation: Federation; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, async (ctx, request) => { + const verification = await featureInteraction.verifyRequest(ctx, { + request, + authentication: ctx.authentication, + }); + if (!verification.verified) return; + // verification.requester is the owner of the collection, who signed + // the request: + console.log(verification.requester.href); + }, { allowActorless: true }); +~~~~ + +An activity that declares an `actor` is never affected by this option: its +actor must be authenticated as usual. An `actor` that ID accessors cannot +read, such as an anonymous or blank node actor, still counts as a declared +actor, so such an activity is rejected rather than treated as having no actor. + +> [!WARNING] +> Do not set `allowActorless` for a listener that relies on the activity's +> actor, such as one for `Undo` or `Delete`, unless the listener checks +> `InboxContext.authentication` itself. Otherwise, any authenticated server +> could act on objects that the listener only matches by the activity's +> `object`. + +[FEP-7aa9]: https://w3id.org/fep/7aa9 + + Determining the recipient of an activity ---------------------------------------- diff --git a/docs/manual/integration.md b/docs/manual/integration.md index 1cc11ae52..891cef7f8 100644 --- a/docs/manual/integration.md +++ b/docs/manual/integration.md @@ -390,6 +390,13 @@ const app = new Hono(); app.use(federation(fedi, (ctx) => "context data")); // [!code highlight] ~~~~ +When Fedify rejects the request's `Accept` header, a matched application route +owns its response, including the default 404 that results when the route calls +`next()`. Downstream wildcard middleware also owns its response, since it can +return the same 404 text as Hono's default not-found handler. The 406 fallback +applies only when no downstream handler matches. Place middleware that should +wrap the 406 response before the Fedify middleware. + [Hono]: https://hono.dev/ diff --git a/docs/manual/interaction-controls.md b/docs/manual/interaction-controls.md index 5f8a37f5e..2bc910508 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,133 @@ 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. + +### Feature requests + +*This behavior is available since Fedify 2.5.0.* + +`featureInteraction.verifyRequest()` never trusts the `FeaturedCollection` that +a request embeds, nor one passed as `resolvedInteractingObject`. It always +fetches the collection from its ID: an ordinary collection has to be served from +the origin of its ID, and an [FEP-ef61] portable collection has to satisfy the +portable object policy. The collection's owner is the only `attributedTo` value +of that document; a collection with several attribution values, including +anonymous ones, is rejected. The owner has to be the requester. + +Mastodon sends `FeatureRequest` activities without an `actor`. The requester +of such a request is the owner of the collection, who must have been +authenticated by Fedify, so pass the [authentication evidence] of the inbox +listener as the `authentication` option. The listener has to be registered +with the `allowActorless` option to receive such requests at all: + +~~~~ typescript twoslash +import type { Federation } from "@fedify/fedify"; +import { featureInteraction } from "@fedify/interaction-controls"; +import { FeatureRequest } from "@fedify/vocab"; +declare const federation: Federation; +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, async (ctx, request) => { + const verification = await featureInteraction.verifyRequest(ctx, { + request, + authentication: ctx.authentication, + }); + if (!verification.verified) { + if ( + verification.failure.category === "unverifiable" && + verification.failure.transient + ) { + // Throw so that the inbox queue retries the request later: + throw new Error("The collection cannot be fetched for now."); + } + return; + } + // ... + }, { allowActorless: true }); +~~~~ + +With `authentication`, the helper also checks that the request it verifies is +the one Fedify authenticated, counting every `actor`, `object`, and +`instrument` value, including those typed parsing drops: a request with more +than one `object` or `instrument` is rejected as `ambiguousRequest`. A request +that declares an actor is accepted only if that actor was authenticated. +Without `authentication`, a request without an actor is rejected as +`missingActor`, and the value counts are checked on the parsed request only. +When the request is given as a URL, it is fetched, and its actors are +authenticated by the web origin it is served from, as long as neither the +request nor its actors are portable. + +[FEP-ef61]: https://w3id.org/fep/ef61 +[authentication evidence]: ./inbox.md#authenticated-principals + +### 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`. `featureInteraction` ignores +`resolvedInteractingObject`; see [*Feature requests*](#feature-requests). + +### 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 +437,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 +447,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/docs/manual/mq.md b/docs/manual/mq.md index 7917f2c44..e5480f1c6 100644 --- a/docs/manual/mq.md +++ b/docs/manual/mq.md @@ -83,7 +83,9 @@ Pros : Persistent, scalable, easy to set up, native retry with exponential backoff. Cons -: Only available in Deno runtime. +: Only available in Deno runtime. Deno KV limits a message to 64 KiB, so an + incoming activity too large for it is processed immediately instead of + being enqueued (since Fedify 2.5.0). ~~~~ typescript import { createFederation } from "@fedify/fedify"; @@ -921,6 +923,20 @@ you can set the `nativeRetrial` property to `true` to indicate this. When this property is `true`, Fedify will skip its own retry logic and rely on your backend to handle retries, avoiding duplicate retry mechanisms. +### Declare `~MessageQueue.maxMessageSize` (optional) + +*This API is available since Fedify 2.5.0.* + +If your backend cannot hold messages larger than a certain size, set the +`maxMessageSize` property to that size in bytes. Fedify compares it with +a conservative estimate of a message's serialized size: the length of its JSON +serialization encoded in UTF-8, plus 8 bytes for each JSON value in it, plus +what encoding strings beyond Latin-1 as UTF-16 adds, so that it also bounds +binary serialization formats. When an incoming activity would +make a larger inbox message, Fedify processes it right away instead of +enqueueing it, as if there were no inbox queue, so that the activity is not +lost. Leave it unset if your backend has no practical limit. + ### Implement `~MessageQueue.getDepth()` method (optional) *This API is available since Fedify 2.3.0.* diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index 44eb80f45..e718bb8d8 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -233,6 +233,7 @@ spans: | `ld_signatures.verify` | Internal | Verifies the Linked Data signature. | | `object_integrity_proofs.sign` | Internal | Makes the object integrity proof. | | `object_integrity_proofs.verify` | Internal | Verifies the object integrity proof. | +| `object_integrity_proofs.verify_object` | Internal | Verifies an object's proofs and attribution. | | `webfinger.handle` | Server | Handles the WebFinger request. | | `webfinger.lookup` | Client | Looks up the WebFinger resource. | @@ -546,6 +547,10 @@ Fedify records the following OpenTelemetry metrics: with no signature header are reported as `activitypub.signature.result=missing` and do not carry a `http_signatures.failure_reason`. + - `activitypub.verification.failure_reason` (Linked Data and Object + Integrity Proofs, on failure rows) is one of `invalidSignature`, + `keyFetchError`, `noSignature`, `signatureVerificationFailed`, + `uncoveredAttribution`, `missingOwner`, or `proofPolicy`. - `ld_signatures.type` (Linked Data only) is recorded only for the spec-supported `RsaSignature2017` type. - `object_integrity_proofs.cryptosuite` (Object Integrity Proofs @@ -557,6 +562,17 @@ Fedify records the following OpenTelemetry metrics: (`http_signatures.verify`, `ld_signatures.verify`, `object_integrity_proofs.verify`) for trace-level investigation. +The [inbox request observer](./inbox.md#observing-inbox-requests) exposes the +same verification evidence directly to application code, independently of +trace sampling. Actual public keys stay in the report and are not emitted as +telemetry attributes. Inbox spans record `activitypub.authentication.status` +and `activitypub.inbox.disposition`. A rejected final authentication decision +also sets the bounded `activitypub.verification.failure_reason` attribute. +Linked Data Signature and individual proof measurements use this common +failure attribute as well. A cryptographically valid proof still records +`verified` when a later attribution or portable proof policy rejects its +object; the rejection belongs to the object/inbox evaluation. + `activitypub.signature.key_fetch.duration` : `activitypub.signature.kind` is always present (same values as above). `activitypub.signature.key_fetch.result` is always present and is one @@ -964,82 +980,85 @@ for ActivityPub as of November 2024. However, Fedify provides a set of semantic [attributes] for ActivityPub. The following table shows the semantic attributes for ActivityPub: -| Attribute | Type | Description | Example | -| -------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | -| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | -| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | -| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | -| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | -| `activitypub.circuit_breaker.previous_state` | string | Previous queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"closed"` | -| `activitypub.circuit_breaker.state` | string | Current queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"open"` | -| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | -| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | -| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | -| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | -| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | -| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | -| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`key_owner`, `actor_fetch`, or `none`). | `"actor_fetch"` | -| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | -| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | -| `activitypub.collection.kind` | string | The bounded collection kind: `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, `featured_tags`, or `custom`. | `"followers"` | -| `activitypub.collection.page` | boolean | Whether the collection request targets a cursor page rather than the collection object. | `false` | -| `activitypub.collection.result` | string | Terminal collection request outcome: `served`, `not_found`, `not_acceptable`, `unauthorized`, or `error`. | `"served"` | -| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | -| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | -| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | -| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | -| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | -| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | -| `activitypub.remote.host` | string | The host of the remote ActivityPub server, including any non-default port. | `"example.com:8443"` | -| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | -| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | -| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | -| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | -| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | -| `fedify.federation.instance_id` | string | Opaque per-Federation instance identifier used to distinguish queue depth series on a shared `MeterProvider`. | `"fedify-1"` | -| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | -| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | -| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | -| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | -| `fedify.collection.dispatcher` | string | The collection dispatcher family: `built_in` or `custom`. | `"built_in"` | -| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | -| `fedify.collection.items` | number | The number of materialized items in the collection response or page. It can be less than the total items. | `10` | -| `fedify.queue.role` | string | The Fedify queue role: `inbox`, `outbox`, `fanout`, or `shared` for queue depth rows where one queue backs multiple roles. | `"outbox"` | -| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | -| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | -| `fedify.queue.depth.state` | string | Queue depth count kind: `queued`, `ready`, or `delayed`. | `"queued"` | -| `fedify.queue.roles` | string | Comma-separated queue roles when one queue instance backs multiple roles. | `"fanout,inbox,outbox"` | -| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | -| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | -| `fedify.task.name` | string | The name of a custom background task: always on the `fedify.task` span; on the task's `fedify.queue.task.*` run metrics only for a registered task (omitted for an `unknown_task` drop, keeping cardinality bounded). | `"sendDigest"` | -| `fedify.task.attempt` | int | The zero-based attempt number of a custom background task, on the `fedify.task` span. | `0` | -| `fedify.task.failure_reason` | string | Why a custom background task failed: `deserialization`, `validation`, `unknown_task`, `handler`, or `retry_enqueue`. Set only on a terminal failure. | `"validation"` | -| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | -| `http.response.status_code` | int | The HTTP response status code. | `200` | -| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` | -| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` | -| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` | -| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` | -| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` | -| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` | -| `http_signatures.digest.{algorithm}` | string | The digest of the HTTP request body in hexadecimal. The `{algorithm}` is the digest algorithm (e.g., `sha`, `sha-256`). | `"d41d8cd98f00b204e9800998ecf8427e"` | -| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` | -| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | -| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | -| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | -| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | -| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | -| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | -| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | -| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | +| Attribute | Type | Description | Example | +| -------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | +| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | +| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | +| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | +| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | +| `activitypub.circuit_breaker.previous_state` | string | Previous queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"closed"` | +| `activitypub.circuit_breaker.state` | string | Current queued outbox circuit breaker state: `closed`, `open`, or `half_open`. | `"open"` | +| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | +| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | +| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | +| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | +| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | +| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | +| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`key_owner`, `actor_fetch`, or `none`). | `"actor_fetch"` | +| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | +| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | +| `activitypub.collection.kind` | string | The bounded collection kind: `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, `featured_tags`, or `custom`. | `"followers"` | +| `activitypub.collection.page` | boolean | Whether the collection request targets a cursor page rather than the collection object. | `false` | +| `activitypub.collection.result` | string | Terminal collection request outcome: `served`, `not_found`, `not_acceptable`, `unauthorized`, or `error`. | `"served"` | +| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | +| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | +| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | +| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | +| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | +| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | +| `activitypub.remote.host` | string | The host of the remote ActivityPub server, including any non-default port. | `"example.com:8443"` | +| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | +| `activitypub.authentication.status` | string | The final inbox authentication decision: `verified`, `rejected`, `skipped`, or `notDetermined`. | `"verified"` | +| `activitypub.inbox.disposition` | string | The inbox delivery outcome: `processed`, `enqueued`, `duplicate`, `unhandled`, `customResponse`, `rejected`, `failed`, or `exception`. | `"processed"` | +| `activitypub.verification.failure_reason` | string | The bounded failure reason. Signature/proof verification uses `invalidSignature`, `keyFetchError`, `noSignature`, `signatureVerificationFailed`, `uncoveredAttribution`, `missingOwner`, or `proofPolicy`. Rejected inbox authentication uses `verificationFailed`, `actorKeyMismatch`, `proofPolicy`, `invalidNonce`, or `activityOriginMismatch`. | `"invalidSignature"` | +| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | +| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | +| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | +| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | +| `fedify.federation.instance_id` | string | Opaque per-Federation instance identifier used to distinguish queue depth series on a shared `MeterProvider`. | `"fedify-1"` | +| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | +| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | +| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | +| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | +| `fedify.collection.dispatcher` | string | The collection dispatcher family: `built_in` or `custom`. | `"built_in"` | +| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | +| `fedify.collection.items` | number | The number of materialized items in the collection response or page. It can be less than the total items. | `10` | +| `fedify.queue.role` | string | The Fedify queue role: `inbox`, `outbox`, `fanout`, or `shared` for queue depth rows where one queue backs multiple roles. | `"outbox"` | +| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | +| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | +| `fedify.queue.depth.state` | string | Queue depth count kind: `queued`, `ready`, or `delayed`. | `"queued"` | +| `fedify.queue.roles` | string | Comma-separated queue roles when one queue instance backs multiple roles. | `"fanout,inbox,outbox"` | +| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | +| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | +| `fedify.task.name` | string | The name of a custom background task: always on the `fedify.task` span; on the task's `fedify.queue.task.*` run metrics only for a registered task (omitted for an `unknown_task` drop, keeping cardinality bounded). | `"sendDigest"` | +| `fedify.task.attempt` | int | The zero-based attempt number of a custom background task, on the `fedify.task` span. | `0` | +| `fedify.task.failure_reason` | string | Why a custom background task failed: `deserialization`, `validation`, `unknown_task`, `handler`, or `retry_enqueue`. Set only on a terminal failure. | `"validation"` | +| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | +| `http.response.status_code` | int | The HTTP response status code. | `200` | +| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` | +| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` | +| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` | +| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` | +| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` | +| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` | +| `http_signatures.digest.{algorithm}` | string | The digest of the HTTP request body in hexadecimal. The `{algorithm}` is the digest algorithm (e.g., `sha`, `sha-256`). | `"d41d8cd98f00b204e9800998ecf8427e"` | +| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` | +| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | +| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | +| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | +| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | +| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | +| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | +| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | +| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | [attributes]: https://opentelemetry.io/docs/specs/otel/common/#attribute [OpenTelemetry Semantic Conventions]: https://opentelemetry.io/docs/specs/semconv/ diff --git a/docs/manual/portable.md b/docs/manual/portable.md index 7b5041865..1721111d1 100644 --- a/docs/manual/portable.md +++ b/docs/manual/portable.md @@ -1,8 +1,9 @@ --- description: >- Fedify supports a profile of FEP-ef61 portable objects. This section - explains which parts of FEP-ef61 Fedify implements, where it deliberately - differs from the current FEP text, and what it does not support yet. + walks through running portable actors, and explains which parts of + FEP-ef61 Fedify implements, where it deliberately differs from the current + FEP text, and what it does not support yet. --- Portable objects @@ -23,16 +24,530 @@ FEP-ef61 is still a draft, and some of the specifications it relies on leave questions open. Fedify therefore implements a *profile* of FEP-ef61: most of what a gateway and a consumer of portable objects need, with a few deliberate choices that differ from the current FEP text or rest on semantics that are -not settled yet, and without some parts of the FEP. This section describes -that profile as a whole, so that you and other implementers can tell what to -expect from a Fedify server. Each feature is explained in detail in the -section it links to. +not settled yet, and without some parts of the FEP. + +This chapter first walks through running portable actors with Fedify, and +then describes the profile as a whole, so that you and other implementers can +tell what to expect from a Fedify server. Each feature is explained in detail +in the section it links to. [FEP-ef61]: https://w3id.org/fep/ef61 [DID]: https://www.w3.org/TR/did-core/ [FEP-8b32]: https://w3id.org/fep/8b32 +Running portable actors +----------------------- + +This section shows how to host a portable actor in a Fedify application, from +creating its DID to sending and receiving activities. Each step links to the +sections of the manual that explain its APIs in detail. The examples assume a +single server, `https://example.com`, that acts as the actor's only gateway; +see [*Running on several gateways*](#running-on-several-gateways) for more. + +### Creating a DID + +A portable actor is identified by a [DID] rather than by a server, and +everything it publishes is signed with a key of the DID. Fedify supports +`did:key` DIDs made from Ed25519 public keys, so generate an Ed25519 key pair +for each portable actor, and make its DID with `exportDidKey()`: + +~~~~ typescript twoslash +import { exportJwk, generateCryptoKeyPair } from "@fedify/fedify"; +import { exportDidKey } from "@fedify/vocab-runtime"; + +const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519"); +const did = await exportDidKey(publicKey); // did:key:z6Mk... +// The verification method of the DID's key, which proofs refer to: +const keyId = new URL(`${did}#${did.slice("did:key:".length)}`); + +// Store the DID and the key pair along with the actor, e.g., as JWKs: +const jwks = { + publicKey: await exportJwk(publicKey), + privateKey: await exportJwk(privateKey), +}; +~~~~ + +The DID is the actor's identity: every ID the actor has is built from it, and +whoever holds its private key can act as the actor. Keep the private key +safe, and do not lose it, since Fedify has no workflow for rotating it or for +moving an actor to another DID. Store the DID along with the actor, rather +than taking it from requests. See the [*Portable IDs* +section](./context.md#portable-ids) of the *Context* chapter for more. + +### Dispatching the actor + +The actor dispatcher returns a portable actor as it would an ordinary actor. +Build its portable ID from the dispatcher's path and the DID with +`~Context.getPortableActorUri()`, and sign the actor with the DID's key by +`signObject()`. Its inbox and collections get portable IDs as well, +and its `gateways` lists the servers that host it, the first of which gives +the domain of its fediverse handle: + +~~~~ typescript twoslash +import { + createFederation, + MemoryKvStore, + signObject, +} from "@fedify/fedify"; +import { Person } from "@fedify/vocab"; +interface User { + identifier: string; + username: string; + did: string; + didKeyPair: CryptoKeyPair; + gatewayKeyPair: CryptoKeyPair; +} +async function findUser(_identifier: string): Promise { + return null; +} +async function findUserByUsername(_username: string): Promise { + return null; +} +function getDidKeyId(did: string): URL { + return new URL(`${did}#${did.slice("did:key:".length)}`); +} +// ---cut-before--- +const federation = createFederation({ + kv: new MemoryKvStore(), + origin: "https://example.com", +}); + +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + const user = await findUser(identifier); + if (user == null) return null; + // A request through the gateway endpoint, e.g., + // GET /.well-known/apgateway/did:key:z6Mk.../users/alice, names the DID + // in its path, so make sure that it is the user's: + if ( + ctx.portableRequest != null && + ctx.portableRequest.authority !== user.did + ) { + return null; + } + // This server's gateway keys for the actor (see below): + const keys = await ctx.getActorKeyPairs(identifier); + return await signObject( + new Person({ + // ap+ef61://did:key:z6Mk.../users/alice + id: ctx.getPortableActorUri(identifier, user.did), + preferredUsername: user.username, + inbox: ctx.getPortableInboxUri(identifier, user.did), + outbox: ctx.getPortableOutboxUri(identifier, user.did), + followers: ctx.getPortableFollowersUri(identifier, user.did), + gateways: [new URL("https://example.com")], + publicKeys: keys.map((key) => key.cryptographicKey), + assertionMethods: keys.map((key) => key.multikey), + }), + user.didKeyPair.privateKey, + getDidKeyId(user.did), // did:key:z6Mk...#z6Mk... + ); + }) + .setKeyPairsDispatcher(async (ctx, identifier) => { + const user = await findUser(identifier); + // This server's own key pair for the actor, not the DID's key pair: + return user == null ? [] : [user.gatewayKeyPair]; + }) + .mapPortableActorId(async (ctx, identifier) => { + const user = await findUser(identifier); + return user == null + ? null + : ctx.getPortableActorUri(identifier, user.did); + }) + .mapHandle(async (ctx, username) => { + const user = await findUserByUsername(username); + return user?.identifier ?? null; + }); +~~~~ + +With this dispatcher, Fedify serves the actor in several ways: + + - Through the FEP-ef61 gateway endpoint, at the actor's compatible + identifier, e.g., + `https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice`, + which is how other servers retrieve it. Fedify serves the actor there + only if its ID is the requested portable ID and it has a proof made with + a key of the DID, and responds with `500 Internal Server Error` if the + proof is missing. + - Through WebFinger, as `@alice@example.com`, whose `self` link is the + compatible identifier above. The domain comes from the first gateway in + the actor's `gateways`, which has to be an HTTP(S) origin. + - At its ordinary URL, e.g., `https://example.com/users/alice`, as before. + +See the [*Portable actors and WebFinger* +section](./actor.md#portable-actors-and-webfinger) for the details. + +The key pairs dispatcher and `~ActorCallbackSetters.mapPortableActorId()` +give the actor *gateway keys*: this server's own keys for the actor, which +sign the HTTP requests that the server makes on behalf of the actor, such as +deliveries. They never sign the actor's activities or objects, which only +the DID's key does. The actor document lists them in its +`assertionMethods`, so that other servers can verify the HTTP Signatures. +See the [*Gateway keys of portable actors* +section](./actor.md#gateway-keys-of-portable-actors). + +> [!TIP] +> Software that does not support FEP-ef61 may refuse an actor whose ID is +> a portable ID. If your actors have to interoperate with such software, +> identify them by their compatible identifiers instead; see the [*Compatible +> identifiers as actor IDs* +> section](./actor.md#compatible-identifiers-as-actor-ids). + +### Serving objects and collections + +Object dispatchers serve portable objects through the gateway endpoint in +the same way, e.g., +`GET /.well-known/apgateway/did:key:z6Mk.../users/alice/notes/1` by +the dispatcher for `/users/{identifier}/notes/{id}`. Give such an object +the portable ID that `~Context.getPortableObjectUri()` builds, and sign it +with the DID's key: + +~~~~ typescript twoslash +import { type Federation, signObject } from "@fedify/fedify"; +import { Note, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { withGatewayHints } from "@fedify/vocab-runtime"; +const federation = null as unknown as Federation; +interface Post { content: string; didKeyPair: CryptoKeyPair } +async function findPost( + _did: string, + _identifier: string, + _id: string, +): Promise { + return null; +} +function getDidKeyId(did: string): URL { + return new URL(`${did}#${did.slice("did:key:".length)}`); +} +// ---cut-before--- +federation.setObjectDispatcher( + Note, + "/users/{identifier}/notes/{id}", + async (ctx, values) => { + // Ordinary requests are omitted for brevity: + if (ctx.portableRequest == null) return null; + const { authority } = ctx.portableRequest; + // Look the post up by the DID as well, since anyone can ask for any DID: + const post = await findPost(authority, values.identifier, values.id); + if (post == null) return null; + return await signObject( + new Note({ + // ap+ef61://did:key:z6Mk.../users/alice/notes/1 + id: ctx.getPortableObjectUri(Note, values), + // ap+ef61://did:key:z6Mk.../users/alice?@gateway=https%3A%2F%2Fexample.com + attribution: withGatewayHints( + ctx.getPortableActorUri(values.identifier), + [new URL("https://example.com")], + ), + to: PUBLIC_COLLECTION, + content: post.content, + }), + post.didKeyPair.privateKey, + getDidKeyId(authority), + ); + }, +); +~~~~ + +Fedify serves only publicly addressed portable objects unless the dispatcher +has an authorization predicate, since a gateway must not serve a non-public +object to anyone but its audience; `~RequestContext.isSignedByAudience()` +helps to write such a predicate. Return a signed `Tombstone` for a deleted +object, which is served with `410 Gone`. See the [*Serving portable objects* +section](./object.md#serving-portable-objects) and the sections that follow +it. + +Collection dispatchers, such as the outbox and followers dispatchers, serve +the actor's portable collections through the gateway endpoint as well, if the +actor's corresponding property has the portable ID that the matching method +builds, e.g., if its `outbox` is what `~Context.getPortableOutboxUri()` +returns. The collections themselves need no proofs, but the portable +activities and objects embedded in them do. See the +[*Portable collections* section](./collections.md#portable-collections). + +### Receiving activities + +A portable actor's inbox is reached through the gateway endpoint as well, +e.g., +`POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox`, and Fedify +routes it by the path after the DID to the same inbox listeners as the ordinary +inbox. Fedify accepts such a delivery only if the actor dispatcher returns a +portable actor whose `inbox` is the requested inbox and whose `gateways` +include this server. + +In a listener, `~Context.parseUri()` with the `portable` option tells which +of your portable actors an activity is about, and its `authority` property +has the DID, which you have to check against the one you store: + +~~~~ typescript twoslash +import { type Federation, signObject } from "@fedify/fedify"; +import { Accept, Follow, Note } from "@fedify/vocab"; +import { withGatewayHints } from "@fedify/vocab-runtime"; +const federation = null as unknown as Federation; +interface User { identifier: string; did: string; didKeyPair: CryptoKeyPair } +async function findUser(_identifier: string): Promise { + return null; +} +async function addFollower(_user: User, _follower: URL): Promise {} +function getDidKeyId(did: string): URL { + return new URL(`${did}#${did.slice("did:key:".length)}`); +} +// ---cut-before--- +federation + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.objectId == null) return; + const parsed = ctx.parseUri(follow.objectId, { portable: true }); + if (parsed?.type !== "actor") return; + const user = await findUser(parsed.identifier); + if (user == null || parsed.authority !== user.did) return; + const follower = await follow.getActor(ctx); + if (follower?.id == null) return; + await addFollower(user, follower.id); + const accept = await signObject( + new Accept({ + id: ctx.getPortableObjectUri( + Note, // Any object dispatcher's path would do. + { identifier: user.identifier, id: crypto.randomUUID() }, + user.did, + ), + // With location hints, so that the recipient can fetch the actor: + actor: withGatewayHints( + ctx.getPortableActorUri(user.identifier, user.did), + [new URL("https://example.com")], + ), + // By its ID, as the received Follow may carry a proof that no longer + // verifies once it is embedded: + object: follow.id, + }), + user.didKeyPair.privateKey, + getDidKeyId(user.did), + ); + await ctx.sendActivity( + { identifier: user.identifier }, + follower, + accept, + { normalizeExistingProofs: true }, + ); + }); +~~~~ + + - Fedify accepts an activity of a portable actor, or one with a portable + ID, only if it has a valid proof made by that DID; an HTTP Signature does + not authenticate it. Portable objects embedded in an activity need their + own proofs. See the [*Portable actors* section](./inbox.md#portable-actors) + and the [*Compound portable objects* + section](./inbox.md#compound-portable-objects) of the *Inbox listeners* + chapter. + - Accessors such as `~Follow.getActor()` verify the portable objects they + fetch, since activities that inboxes receive use + `~Context.verifyPortableObject` by default. See the [*Default verifiers* + section](./vocab.md#default-verifiers). + - Fedify forwards activities received in a portable inbox to the actor's + other gateways, if the key–value store supports `~KvStore.cas()`. + Configure the `origin` option, as in the example above, so that Fedify + can tell which gateway it is. See the [*Portable inboxes* + section](./inbox.md#portable-inboxes) and the [*Forwarding to other + gateways* section](./inbox.md#forwarding-to-other-gateways). + +### Sending activities + +The activities of a portable actor are signed with the DID's key, which +the key pairs dispatcher does not know. So sign them with `signObject()` +before sending them, as the `Accept` above is, and pass +`normalizeExistingProofs: true` to `~Context.sendActivity()` so that the +activity is sent in the form that the proof covers. If an activity embeds a +portable object, such as the `Note` of a `Create`, sign the object first, and +then the activity: + +~~~~ typescript twoslash +import { type Context, signObject } from "@fedify/fedify"; +import { Create, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { withGatewayHints } from "@fedify/vocab-runtime"; +const ctx = null as unknown as Context; +interface User { identifier: string; did: string; didKeyPair: CryptoKeyPair } +const user = null as unknown as User; +function getDidKeyId(did: string): URL { + return new URL(`${did}#${did.slice("did:key:".length)}`); +} +// ---cut-before--- +const gateways = [new URL("https://example.com")]; +// A reference to the actor, with location hints that tell where to get it: +// ap+ef61://did:key:z6Mk.../users/alice?@gateway=https%3A%2F%2Fexample.com +const actor = withGatewayHints( + ctx.getPortableActorUri(user.identifier, user.did), + gateways, +); +const note = await signObject( + new Note({ + id: ctx.getPortableObjectUri( + Note, + { identifier: user.identifier, id: crypto.randomUUID() }, + user.did, + ), + attribution: actor, + to: PUBLIC_COLLECTION, + content: "Hello, world!", + }), + user.didKeyPair.privateKey, + getDidKeyId(user.did), +); +const create = await signObject( + new Create({ + id: ctx.getPortableObjectUri( + Note, // Any object dispatcher's path would do. + { identifier: user.identifier, id: crypto.randomUUID() }, + user.did, + ), + actor, + to: PUBLIC_COLLECTION, + object: note, + }), + user.didKeyPair.privateKey, + getDidKeyId(user.did), +); +await ctx.sendActivity( + { identifier: user.identifier }, + "followers", + create, + { normalizeExistingProofs: true }, +); +~~~~ + + - The activity has to have a portable ID of the actor's DID, and it is + signed with the DID's key only; `~Context.sendActivity()` throws + a `TypeError` otherwise, e.g., for an unsigned portable activity. The + HTTP requests are signed with the gateway keys. See the [*Choosing the + proof key* section](./send.md#choosing-the-proof-key). + - Location hints go on references to portable actors, such as `actor` and + `attributedTo`, but not on an object's own `id`, and they have to be + added before signing. See the [*Location hints* + section](./vocab.md#location-hints). + - An embedded portable object can be verified on its own only if it is signed + with `signObject()` before it is embedded. See the [*Producing + a compound document* section](./send.md#producing-a-compound-document). + - Recipients that are portable actors themselves are delivered to through + their gateways. If your followers collection dispatcher returns + `Recipient` objects, include the `~Recipient.gateways` of portable + followers. See the [*Delivering to portable actors* + section](./send.md#delivering-to-portable-actors). + +### Attaching media + +A portable object refers to its media, such as images, by [hashlinks], which +carry the digests of the media, so that the media can be retrieved from any +gateway and verified. Compute the digest with `computeDigestMultibase()`, +and make the hashlink with `createHashlink()`: + +~~~~ typescript twoslash +import { Image } from "@fedify/vocab"; +import { + computeDigestMultibase, + createHashlink, +} from "@fedify/vocab-runtime"; +declare const bytes: Uint8Array; +// ---cut-before--- +const digestMultibase = await computeDigestMultibase(bytes); +const image = new Image({ + url: new URL(createHashlink(digestMultibase)), // hl:zQm... + mediaType: "image/png", + digestMultibase, +}); +~~~~ + +To serve the media through the gateway endpoint, e.g., +`GET /.well-known/apgateway/hl:zQm...`, register a hashlink media dispatcher +with `~Federatable.setHashlinkMediaDispatcher()`. Fedify does not check what +the dispatcher serves against the digest, so store the media under their +digests. See the [*Serving hashlink media* +section](./object.md#serving-hashlink-media). To retrieve and verify the media +of others' portable objects, use `fetchPortableMedia()`; see the +[*Portable media* section](./vocab.md#portable-media). + +[hashlinks]: https://datatracker.ietf.org/doc/html/draft-sporny-hashlink-07 + +### Looking up portable objects + +`~Context.lookupObject()` looks up portable objects of others by their +portable IDs, their compatible identifiers, and the fediverse handles of +portable actors, and returns them only if their proofs are valid: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +// Through WebFinger: +const actor = await ctx.lookupObject("@alice@example.com"); +// A portable ID without location hints needs gateways to ask: +const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", { + gateways: ["https://example.com"], +}); +~~~~ + +Property accessors of the returned objects, and of the activities that +inboxes receive, verify the portable objects they dereference, too. For +objects obtained elsewhere, pass a `Context` as the options of an accessor, +e.g., `await create.getObject(ctx)`, to verify them. + +A portable actor's own collections, such as its outbox, have no location +hints, so accessors fetch them, and the objects under the same DID that they +lead to, through the actor's `gateways`: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import { isActor } from "@fedify/vocab"; +const ctx = null as unknown as Context; +// ---cut-before--- +const actor = await ctx.lookupObject("@alice@example.com"); +if (isActor(actor)) { + const outbox = await actor.getOutbox(ctx); + if (outbox != null) { + for await (const item of ctx.traverseCollection(outbox)) { + console.log(item.id?.href); + } + } +} +~~~~ + +Unsigned collections are accepted only from gateways that their owners +list, as described in the [*Portable collections* +section](./vocab.md#portable-collections) of the *Vocabulary* chapter. See the +[*Portable objects* section](./context.md#portable-objects) of the *Context* +chapter and the +[*Dereferencing portable references* section](./vocab.md#dereferencing-portable-references). + +### Running on several gateways + +The point of a portable actor is that it can be hosted by several servers, +so that it survives any of them. Fedify, however, does not copy actors, +objects, or collections between gateways; the only built-in exchange between +them is the forwarding of activities that portable inboxes receive. To host +an actor on several gateways: + + - List every gateway in the actor's `gateways`, in the order of preference. + Each of them has to serve the actor, its objects, and its collections, + and to accept deliveries to its inbox, e.g., by running an application + like the one above with the same data. + - List the gateway keys of every gateway in the actor's + `assertionMethods`, not only those of the server that serves the + document, so that each gateway's HTTP Signatures can be verified. See + the [*Gateway keys of portable actors* + section](./actor.md#gateway-keys-of-portable-actors). + - Decide which servers hold the DID's private key. A gateway that signs + documents when it serves them, like the example above, needs the key. + Otherwise, the application has to store the signed documents and keep + them in sync across the gateways by itself. Keep the received JSON of + such documents rather than serializing vocabulary objects parsed from + them, which can change what their proofs cover; see the [*Staying + compatible* section](#staying-compatible). + +### Testing + +The mock federation and contexts of `@fedify/testing` support portable IDs, +portable object verification with fixture document loaders, gateway +requests, and hashlink media. See the [*Testing* chapter](./test.md). + + Supported features ------------------ @@ -333,16 +848,31 @@ Other limits consumers, Fedify included, do not accept them unsecured, so they may not be usable by others yet. -The following work for Fedify 2.4 is still in progress: +[FEP-ae97]: https://w3id.org/fep/ae97 + - - Support for portable objects in the `fedify` CLI ([#1156]). - - Testing against other implementations of FEP-ef61 ([#1097]). Until - then, Fedify's profile has been checked only against Fedify itself and - against fixtures modeled on other implementations. +Interoperability +---------------- + +Besides Fedify itself, Fedify's profile has been tested against [tootik] +v0.25.4 and [Mitra] v5.10.0. Fedify verifies tootik's portable actors, +activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on +activities that a client signed, and both accept portable activities that +Fedify sends. Fedify has not yet verified a portable actor or activity that +Mitra itself signed. Mastodon's handling of portable actors has not +been tested. The [*FEP-ef61 interoperability*][interoperability] section of +*FEDERATION.md* records what was tested and how, along with the limits that +were found, e.g., that tootik v0.25.4 does not recognize compatible +identifiers with explicit ports. + +The `fedify lookup` command looks up and verifies portable objects, which +helps to check what your server serves; see the [*`fedify lookup`* +section](../cli.md#fedify-lookup-looking-up-an-activitypub-object) of the CLI +manual. *[DID]: Decentralized Identifier *[FEP]: Fediverse Enhancement Proposal -[FEP-ae97]: https://w3id.org/fep/ae97 -[#1097]: https://github.com/fedify-dev/fedify/issues/1097 -[#1156]: https://github.com/fedify-dev/fedify/issues/1156 +[tootik]: https://github.com/dimkr/tootik +[Mitra]: https://codeberg.org/silverpill/mitra +[interoperability]: https://github.com/fedify-dev/fedify/blob/main/FEDERATION.md#fep-ef61-interoperability diff --git a/docs/manual/pragmatics.md b/docs/manual/pragmatics.md index beb407c8a..1b99e1f3f 100644 --- a/docs/manual/pragmatics.md +++ b/docs/manual/pragmatics.md @@ -778,6 +778,102 @@ parsing these fields does not verify the credit or authorize an activity. [FEP-22cd]: https://w3id.org/fep/22cd +### License metadata + +`Object.licenses` and `Link.licenses` hold the copyright licenses of a piece of +content as URIs, and actors have a `preferredLicense` for the new objects they +create. Both are defined by [FEP-6757], which recommends a `license` on +content objects such as `Note`, `Article`, `Image`, `Video`, and `Audio`. + +> [!WARNING] +> This implements [FEP-6757]. +> The proposal may change, and few other implementations read these properties +> yet. A license is a claim made by the publisher; Fedify does not enforce it. + +Identify a license by its URI, not by its name or SPDX short identifier. +Prefer the canonical URIs of the [Creative Commons licenses], CC0 +(), the Public Domain Mark +(), or the +[Rights Statements] such as +(“in copyright”): + +~~~~ typescript twoslash +import { Image, Note } from "@fedify/vocab"; + +const note = new Note({ + id: new URL("https://example.com/notes/1"), + content: "Hello, world!", + licenses: [new URL("https://creativecommons.org/licenses/by/4.0/")], + attachments: [ + new Image({ + url: new URL("https://example.com/media/1.jpg"), + mediaType: "image/jpeg", + licenses: [new URL("https://creativecommons.org/publicdomain/mark/1.0/")], + }), + ], +}); +~~~~ + +Multiple licenses are alternatives that a consumer may choose from, so their +order has no meaning. Fedify adds the preloaded `https://w3id.org/fep/6757` +context when license metadata needs it; objects without it keep their existing +output. If you supply an explicit context to +`toJsonLd({ format: "compact", context: ... })`, include the FEP context to get +the `license` and `preferredLicense` term names. + +When reading, Fedify also accepts the `https://schema.org/license` and +`http://creativecommons.org/ns#license` properties that the FEP allows as +alternatives, but only when the object has no `license`; the sets are never +merged. The `http://schema.org/license` property is not read, because +PeerTube uses it for licence descriptions that are not URIs. It writes only +`license`. Values that are not absolute URIs, such as `"CC-BY-4.0"`, are +skipped. Fedify never fetches a license URI. + +A license applies only to the object that carries it. A license on an actor +covers its profile, not the posts it authored; a license on an activity or a +collection does not cover the activity's object or the collection's items. + +#### Missing licenses + +An empty `licenses` array means the object carries no license metadata, and +Fedify does not fill in a default. [FEP-6757] suggests that consumers treat +such content as “all rights reserved,” that is, as if its license were +. Apply that policy where you +decide what to do with the content rather than storing it as the object's +license: + +~~~~ typescript twoslash +import type { Note } from "@fedify/vocab"; +declare const note: Note; +// ---cut-before--- +const allRightsReserved = new URL("https://rightsstatements.org/vocab/InC/1.0/"); +const licenses = note.licenses.length > 0 ? note.licenses : [allRightsReserved]; +~~~~ + +#### Preferred license of an actor + +`preferredLicense` tells a client which license to select by default when the +actor creates a new object; the client should still let the user choose +another: + +~~~~ typescript twoslash +import { Note, Person } from "@fedify/vocab"; +declare const actor: Person; +// ---cut-before--- +const note = new Note({ + attribution: actor.id, + content: "Hello, world!", + licenses: actor.preferredLicense == null ? [] : [actor.preferredLicense], +}); +~~~~ + +It is not a fallback: an object without its own `license` is not licensed under +its author's `preferredLicense`. + +[FEP-6757]: https://w3id.org/fep/6757 +[Creative Commons licenses]: https://creativecommons.org/licenses/ +[Rights Statements]: https://rightsstatements.org/page/1.0/ + ### `Question`: Polls The `Question` type is used for polls. In Mastodon, the question body comes diff --git a/docs/manual/send.md b/docs/manual/send.md index 3dee80ba1..9b6dec732 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1169,9 +1169,14 @@ proof must use a DID URL, and that DID must match the authority of the portable object ID. A portable actor must also have a non-empty `gateways` list whose items are all HTTP(S) URIs with an empty path, query, and fragment; this is checked before the proofs, so the `invalidGateways` reason does not mean that -the proofs are valid. The detailed result distinguishes documents outside the -policy, missing or invalid proofs, actors without valid gateways, unsupported -verification methods, DID mismatches, and successful verification: +the proofs are valid. When a portable actor omits the JSON-LD mapping for +`gateways`, as tootik v0.25.4 does, Fedify accepts the otherwise unmapped +ActivityStreams term only if every proof uses `eddsa-jcs-2022`. These proofs +authenticate the original JSON, including the gateway list. + +The detailed result distinguishes documents outside the policy, missing or +invalid proofs, actors without valid gateways, unsupported verification +methods, DID mismatches, and successful verification: ~~~~ typescript import { verifyPortableObjectProof } from "@fedify/fedify"; @@ -1234,9 +1239,9 @@ the `publicKey` or `assertionMethod` of a portable actor, such as a gateway key, whose ID is the actor's compatible identifier plus a fragment; the actor's proof covers it. Fedify verifies each map against an immutable copy of the received JSON and applies the FEP-ef61 portable-ID and controlling-DID -policy to that map. It does not copy a parent's context into a child, try +policy to that map. It does not copy a parent's context into a child, try several proof-removal rules, or fetch an unpinned context from the live -network. Inputs that exceed the traversal limits are rejected. Fedify does not +network. Inputs that exceed the traversal limits are rejected. Fedify does not return authentication results for only part of a compound document. This profile authenticates JSON snapshots. A child proof may be valid when @@ -1260,9 +1265,10 @@ differ from the parent's: ~~~~ typescript twoslash import { signObject } from "@fedify/fedify"; import { Create, Note } from "@fedify/vocab"; -const noteId = new URL("ap://did:key:z6Mkabc/objects/1"); -const activityId = new URL("ap://did:key:z6Mkabc/activities/1"); -const actorId = new URL("ap://did:key:z6Mkabc/actor"); +import { parseIri } from "@fedify/vocab-runtime"; +const noteId = parseIri("ap://did:key:z6Mkabc/objects/1"); +const activityId = parseIri("ap://did:key:z6Mkabc/activities/1"); +const actorId = parseIri("ap://did:key:z6Mkabc/actor"); const key = null as unknown as CryptoKey; const keyId = new URL("did:key:z6Mkabc#z6Mkabc"); const portableContext = [ @@ -1382,7 +1388,16 @@ keys or pre-sign it instead. Fedify also refuses to send an activity with portable objects if any map in it already carries a proof set, which happens, for example, when `signObject()` is called twice on the same object. The error names the JSON Pointer of the -offending `proof`. These checks only prevent unsupported proof shapes. They +offending `proof`. For the same reason, it refuses to send one in which an +embedded map carries a proof but not its own `@context`, which Fedify inboxes +reject however valid the proof is. An object that you signed with +`signObject()` is embedded with its own `@context`, but one parsed from +a received document is rebuilt under the activity's context, so its proof no +longer covers it (see the warning below). This typically happens to +an `Accept` that embeds the `Follow` it accepts, as Fedify signs activities +with Object Integrity Proofs by default, so refer to such an object by its ID +instead, e.g., `new Accept({ object: follow.id })`. These checks only prevent +unsupported proof shapes. They do not otherwise validate a proof created with a single key. > [!WARNING] diff --git a/docs/manual/vocab.md b/docs/manual/vocab.md index 6bc2225f3..5ddced37a 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -265,6 +265,16 @@ const actor = new Person({ ~~~~ Each gateway must be an HTTP(S) base URI with no path, query, or fragment. +Newly serialized gateway lists use origins without a trailing slash, while +the in-memory `URL` values retain their root path. A parsed actor's default +JSON output can retain the cached spelling of its gateways. Keep the received +JSON when verifying existing proofs. + +For portable actors with inbox and outbox properties, Fedify also reads an +unmapped `gateways` term under the ActivityStreams context, as emitted by +tootik v0.25.4. Explicit JSON-LD mappings take precedence. Parsing this list +does not authenticate it; use portable object verification before trusting +the actor or its gateways. Software that does not understand portable IRIs can still refer to portable objects through *compatible identifiers*: HTTP(S) URLs under a gateway's @@ -434,11 +444,15 @@ gateways with a path are not supported. If you omit it, the accessor uses the `@gateway` location hints in the reference, e.g., `ap://did:key:z6Mk.../actor?@gateway=https%3A%2F%2Fserver1.example`. Since hints come from the document that contains the reference, only the first five -are used. An explicit `gateways` list replaces the hints, and an empty list -turns them off. When there is no gateway to try, the accessor passes the -portable ID itself to the document loader, which lets a custom document loader -retrieve portable objects in its own way. The two checks above apply in that -case as well. +are used. A reference without hints that has the same DID as a portable +actor it was reached from, e.g., the actor's `outbox`, or a page of the outbox, +is fetched through the first five valid gateways in that actor's `gateways` +instead, since references in a portable actor's own document do not need +hints. An explicit `gateways` list replaces the hints and the actor's +gateways, and an empty list turns them off. When there is no gateway to try, +the accessor passes the portable ID itself to the document loader, which lets a +custom document loader retrieve portable objects in its own way. The two +checks above apply in that case as well. Requests to gateways go through the document loader, so an authenticated document loader signs them, which lets you fetch non-public portable objects @@ -529,7 +543,7 @@ instead. The option applies to that call only; it is not passed on. Objects that Fedify parses for you already have `~Context.verifyPortableObject` as their default: activities that inboxes receive, including queued ones, objects that `~Context.lookupObject()` returns, -and the activities passed to the `onOutboxError` callback. So +and the activities passed to the `onOutboxError` callback. So `create.getObject()` in an inbox listener verifies portable objects even without options. A clone does not inherit the default; pass `verifyPortableObject` to `clone()` if you need it. @@ -612,8 +626,8 @@ without a proof, and accepts it only if all of the following hold: its `inbox`, `outbox`, `followers`, `following`, or `liked`. If you dereferenced the collection from an actor that was itself fetched and verified through gateways, that actor document is used; otherwise the - owner is fetched through the same `gateways` option (or its location - hints). + owner is fetched through the same `gateways` option, or else its location + hints or the gateways that the collection was fetched through. - The gateway that served the collection is one of the owner's `gateways`. A collection with a proof is verified by its proof instead, and a collection @@ -821,7 +835,23 @@ const note = await create.getObject(); const note2 = await create.getObject(); ~~~~ -Hydrating the property also affects the JSON-LD representation of the object. +When hydration caches a fetched object, it replaces the property's URL in the +instance whose accessor was called. An unverified portable claim can be +returned without replacing that URL or clearing the JSON-LD cache, so a later +accessor call can fetch it again. +When the result is cached, all references to that instance see the hydrated +property, including references +from a source and its [shallow clone](#immutability) to a shared nested object, +or from an activity that embeds it. Hydrating a top-level property of a clone +does not update the source's property, because their property arrays are +separate. + +Hydration can also affect `toJsonLd()` output, including output from activities +that embed the hydrated object. Objects parsed with `fromJsonLd()` may retain +cached JSON-LD. Caching a fetched object clears the JSON-LD cache only on the +instance whose accessor fetched it; parent caches remain intact. A parent may +therefore serialize differently on its own and when embedded in another +activity. For example, since the following code does not hydrate the `object` property, the JSON-LD representation of the `Create` object has the `object` property @@ -890,16 +920,13 @@ attributes are simplified or omitted for readability): Immutability ------------ -Every object in the Activity Vocabulary is represented as an immutable object. -This means that you cannot change the properties of the object after the object -is instantiated. This is for ensuring the consistency of the objects and the -safety of the objects in the concurrent environment. +Activity Vocabulary objects are immutable in the sense that you cannot assign +new property values after construction. Dereferencing accessors can still +cache fetched objects through [property hydration](#property-hydration). -In order to change the properties of the object, you need to clone the object -with the new properties. Fortunately, the objects have a `clone()` method that -takes an object with the new properties and returns a new object with the new -properties. The following shows an example of changing the `~Object.content` -property of a `Note` object: +To replace property values, use `clone()`. It returns a new instance with the +given values and leaves the source's property values unchanged. The following +example replaces the `~Object.content` property of a `Note` object: ~~~~ typescript{8-10} twoslash import { Note } from "@fedify/vocab"; @@ -915,8 +942,40 @@ const noteInChinese = noteInEnglish.clone({ }); ~~~~ -Parameters of the `clone()` method share the same type with parameters of -the constructor. +The `clone()` method accepts the same property values as the constructor. +It makes a *shallow copy*: the clone has its own property arrays, but nested +objects and URLs are shared with the source. Hydrating a shared nested object +through either instance changes that nested object for both when their accessors +return the shared instance. A parsed source can return a fresh object instead: +if its cached JSON-LD contains an embedded object with its own `@context`, the +accessor re-parses that object on each call. Hydrating that returned object +does not update the nested instance stored in the source or clone. + +If you need to preserve the received representation while resolving properties, +keep the received JSON-LD document. To resolve properties in a separate object +graph, serialize and re-parse the object before hydrating any properties rather +than using `clone()`: + +~~~~ typescript twoslash +import { Create } from "@fedify/vocab"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; +declare const original: Create; +declare const documentLoader: DocumentLoader; +declare const contextLoader: DocumentLoader; +// ---cut-before--- +const options = { documentLoader, contextLoader }; +const copy = await Create.fromJsonLd(await original.toJsonLd(), options); +// Resolve properties on copy while keeping original separate: +const note = await copy.getObject(options); +if (note != null) await note.getAttribution(options); +~~~~ + +This round trip separates nested vocabulary objects; it does not preserve the +original JSON bytes. Pass any custom loaders again, as in the example above. +The copy also does not inherit trust in embedded objects. Under the +[origin-based security model](#origin-based-security-model), accessors may fetch +cross-origin objects again, even if the original already hydrated them. The +fetched data may have changed, or the fetch may fail. Looking up remote objects @@ -1103,12 +1162,28 @@ runtime strings, so the generated encoder cannot distinguish them reliably during JSON-LD serialization and Fedify rejects such schema definitions at code generation time. +*Since Fedify 2.5.0*, `@fedify/vocab-runtime` also exports +`normalizeLanguageTag()`, which replaces a language tag that has an extended +language subtag (extlang) with its canonical form as described in +[RFC 5646, Section 4.5], e.g., `zh-YUE` with `yue`. [`Intl.Locale`] rejects +extlang tags, so normalize such tags before passing them to it. Fedify already +does this when it parses language-tagged strings and language tags: + +~~~~ typescript twoslash +import { normalizeLanguageTag } from "@fedify/vocab-runtime"; + +normalizeLanguageTag("zh-YUE"); // "yue" +new Intl.Locale(normalizeLanguageTag("zh-YUE")).language; // "yue" +normalizeLanguageTag("en-US"); // "en-US" (unchanged) +~~~~ + [`Temporal.Instant`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant [`Temporal.Duration`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration [`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array [BCP 47]: https://www.rfc-editor.org/info/bcp47 [`Intl.Locale`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale [`CryptoKey`]: https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey +[RFC 5646, Section 4.5]: https://www.rfc-editor.org/rfc/rfc5646.html#section-4.5 Origin-based security model diff --git a/docs/manual/webfinger.md b/docs/manual/webfinger.md index fda739f55..8a17bd789 100644 --- a/docs/manual/webfinger.md +++ b/docs/manual/webfinger.md @@ -213,6 +213,24 @@ The WebFinger links dispatcher receives two parameters: > documentation for details on customizing WebFinger through actor properties. +Portable actors +--------------- + +*This API is available since Fedify 2.4.0.* + +The WebFinger endpoint also serves [FEP-ef61] portable actors, whose IDs are +not tied to any server. The domain of such an actor's address comes from +the first gateway in its `gateways` rather than from its ID, and the `self` +link is its compatible identifier on that gateway, e.g., +`https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice`. +See the [*Portable actors and WebFinger* +section](./actor.md#portable-actors-and-webfinger) of the *Actor dispatcher* +chapter for details, and the [*Portable objects* chapter](./portable.md) for +running portable actors in general. + +[FEP-ef61]: https://w3id.org/fep/ef61 + + Looking up WebFinger -------------------- @@ -375,6 +393,65 @@ The available options are: `signal` : An `AbortSignal` for cancelling the request. +`withDiagnostics` +: *Since Fedify 2.5.0.* Whether to return a `WebFingerLookupOutcome` + describing why the lookup failed, instead of collapsing every failure + into `null`. Turned off by default. See + [*Diagnosing a failed lookup*](#diagnosing-a-failed-lookup) below. + +### Diagnosing a failed lookup + +*This option is available since Fedify 2.5.0.* + +By default, `lookupWebFinger()` returns `null` for every kind of failure: +a response saying the resource doesn't exist looks the same as a network +error or a malformed response. Pass `withDiagnostics: true` to get back a +`WebFingerLookupOutcome` instead, which explains why the lookup failed: + +~~~~ typescript twoslash +import { lookupWebFinger } from "@fedify/webfinger"; + +const outcome = await lookupWebFinger("acct:alice@example.com", { + withDiagnostics: true, +}); + +if (outcome.resource != null) { + console.log("Found:", outcome.resource.subject); +} else { + console.log("Failed:", outcome.result); +} +~~~~ + +The `result` field is one of: + +`"found"` +: The lookup returned a resource descriptor. + +`"not_found"` +: The remote responded with `404 Not Found` or `410 Gone`. + +`"invalid"` +: The remote responded with content Fedify could not parse into a + resource descriptor, or the queried resource was malformed. + +`"network_error"` +: No HTTP response was received, e.g., the URL was rejected as a private + address, or the request was aborted. + +`"error"` +: The remote responded with an unexpected non-2xx HTTP response. + +`withDiagnostics: true` classifies the failures listed above into a +`WebFingerLookupOutcome`, but it does not turn every failure into one. +An error `lookupWebFinger()` does not recognize—for example, one while +reading the response body—still rejects the returned promise, so +callers must keep handling a rejected promise even with this option +turned on. + +> [!NOTE] +> `~Context.lookupWebFinger()` does not support `withDiagnostics`; it always +> returns the resource descriptor or `null`. + > [!TIP] > Use `@fedify/webfinger` when you need a lightweight WebFinger client without > the full Fedify framework. If you're already using Fedify, prefer diff --git a/docs/public/og.png b/docs/public/og.png index efa8b56ac..249713829 100644 Binary files a/docs/public/og.png and b/docs/public/og.png differ diff --git a/docs/why.md b/docs/why.md index 4e12d55f6..8cee95fd2 100644 --- a/docs/why.md +++ b/docs/why.md @@ -338,6 +338,105 @@ infrastructure: [SiliconBeest]: https://github.com/SJang1/siliconbeest +### Indiekit: Turning an IndieWeb site into a fediverse account + +[Indiekit] is a Micropub server for IndieWeb sites. Its +[ActivityPub endpoint plugin][indiekit-endpoint-activitypub], built on Fedify, +turns the site itself into a fediverse actor that people on Mastodon, Misskey, +or Pixelfed can find and follow. Posts published through Micropub go out to +followers, and incoming follows, likes, boosts, and replies come back to the +site. + +The plugin also implements a Mastodon-compatible client API, so the site's +owner can read their timeline and reply from Phanpy, Elk, or other Mastodon +apps. Its developer runs it on their own site, [rmendes.net], and has +written about using it day to day. + +[Indiekit]: https://getindiekit.com/ +[indiekit-endpoint-activitypub]: https://github.com/rmdes/indiekit-endpoint-activitypub +[rmendes.net]: https://rmendes.net/ + +### Vegan Home Cooks: A static recipe site with followers + +[Vegan Home Cooks] is a recipe site built with Hugo and served as static +files. A static host cannot do content negotiation or accept inbox +deliveries, so the site gets its fediverse presence from a +[small Cloudflare Worker][veganhomecooks-fedify] that runs Fedify alongside +it. The Worker reads the site's recipe index, turns each new or changed +recipe into an `Article`, and delivers it to followers through Cloudflare +Queues. + +It is a good example of how little code you need when the goal is narrow: +one account, one-way publishing, and no changes to the site that people +actually visit. + +[Vegan Home Cooks]: https://veganhomecooks.online/ +[veganhomecooks-fedify]: https://github.com/hamid-veganhomecooks/veganhomecooks-fedify + +### rss2.pub: Following any feed from the fediverse + +[rss2.pub] is a bridge that turns RSS 2.0 and Atom 1.0 feeds into ActivityPub +accounts. Each registered feed becomes an account, and following it from +Mastodon puts the feed's new entries in your home timeline. You can register +a feed from the web UI or by mentioning the bridge's main account. + +The bridge started out on [BotKit], Fedify's bot framework, and later moved +down to Fedify's dispatchers and its PostgreSQL integration. Its posts list +the feed's authors as additional `attributedTo` actors, which BotKit's +abstractions made awkward, and Fedify let the project own that part of the +model directly while keeping the account URIs it already had. + +[rss2.pub]: https://github.com/moreal/rss2.pub +[BotKit]: https://botkit.fedify.dev/ + +### Economiverse and Menuverse: Federated marketplaces + +[54°Grad Software] is exploring commerce on the fediverse, built on +[FEP-0837] (Federated Marketplaces). The `Proposal` vocabulary they rely on +was added to Fedify in version 2.2.0, and more FEP-0837 types followed in +version 2.3.0. + +[Economiverse] is a proof of concept with two shops and a marketplace that +follows them; new products added to a shop show up in the marketplace through +ActivityPub. [Menuverse] applies the same idea to canteens: each canteen is +an actor that publishes the day's dishes, with allergens and prices, and a +marketplace app collects and filters menus from many canteens. Both are still +in development, but they show Fedify carrying data that has nothing to do with +microblogging. + +[54°Grad Software]: https://codeberg.org/54GradSoftware +[FEP-0837]: https://codeberg.org/fediverse/fep/src/branch/main/fep/0837/fep-0837.md +[Economiverse]: https://codeberg.org/54GradSoftware/economiverse +[Menuverse]: https://codeberg.org/54GradSoftware/menuverse + +### 北雁云依's blog: Replacing a hand-written implementation + +[北雁云依] (BeiyanYunyi) had added ActivityPub to their blog by hand two +years earlier, following tutorials and code borrowed from Wildebeest. It +handled follows and little else. Replies were logged and dropped, and every +update was sent by re-delivering all posts to all followers with no retry. + +In September 2026 they [moved the blog to Fedify][migrate-to-fedify]. Fedify +took over object serialization, signature verification, and signed delivery, +and delivery now runs through Cloudflare Queues. The actor URI, post URIs, and +signing keys stayed the same, so the blog kept its identity and its +followers. + +[北雁云依]: https://blog.yunyi.beiyan.us/ +[migrate-to-fedify]: https://blog.yunyi.beiyan.us/posts/migrateToFedify + +### ActivityPub Fuzzer: Fedify CLI in a research tool + +The [ActivityPub Fuzzer] from Harvard's Berkman Klein Center helps developers +test their servers by emulating messages from known fediverse software, using +data from the Fediverse Schema Observatory. The Fuzzer is not built on +Fedify, but its setup guide recommends +[`fedify tunnel`](./cli.md#fedify-tunnel-exposing-a-local-http-server-to-the-public-internet) +for exposing the local Fuzzer over HTTPS, which shows that Fedify's CLI is +useful well beyond Fedify's own users. + +[ActivityPub Fuzzer]: https://github.com/berkmancenter/activitypub-fuzzer + Conclusion ---------- @@ -368,4 +467,4 @@ Stay flexible If you're looking to build a federated application on the ActivityPub protocol, Fedify offers a powerful and efficient path to success. - + diff --git a/examples/fresh/deno.json b/examples/fresh/deno.json index e36f9c41b..36138585b 100644 --- a/examples/fresh/deno.json +++ b/examples/fresh/deno.json @@ -1,8 +1,8 @@ { "tasks": { "check": "deno fmt --check . && deno lint . && deno check", - "dev": "vite", - "build": "vite build", + "dev": "deno run -A npm:vite@^7.1.3 --configLoader native", + "build": "deno run -A npm:vite@^7.1.3 build --configLoader native", "start": "deno serve -A _fresh/server.js", "update": "deno run -A -r jsr:@fresh/update ." }, diff --git a/mise.toml b/mise.toml index 49dd9b531..686df5226 100644 --- a/mise.toml +++ b/mise.toml @@ -192,9 +192,19 @@ depends = [ "check-versions", "check:workspace-protocol", "check:fixture-usage", + "check:node-modules", ] run = "sacho check" +[tasks."check:node-modules"] +description = "Check that Deno leaves pnpm's node_modules directories alone" +run = ''' +if ! jq -e '.nodeModulesDir == "none"' deno.json > /dev/null; then + echo 'Set nodeModulesDir to "none" so Deno preserves pnpm workspace links.' + exit 1 +fi +''' + [tasks."check:fmt"] description = "Check code formatting" run = "deno fmt --check" @@ -261,6 +271,9 @@ for pkg in ($env.usage_packages | split row " " | where ($it | is-not-empty)) { ''' # Testing +[tasks."test:pack"] +description = "Check that Deno preserves pnpm links and package packing" +run = "node scripts/test_pack.mjs" [tasks."test:deno"] wait_for = ["build"] @@ -292,6 +305,7 @@ depends = [ "test:node", "test:bun", "test:astro-compat", + "test:sveltekit-compat", "test:temporal-polyfill", ] @@ -335,6 +349,11 @@ description = "Test @fedify/astro against supported Astro and runtime versions" depends = ["build"] run = "deno run -A packages/astro/compat/run.ts" +[tasks."test:sveltekit-compat"] +description = "Check @fedify/sveltekit consumer types against SvelteKit 2 and 3" +depends = ["build"] +run = "deno run -A packages/sveltekit/compat/run.ts" + [tasks."test:monitoring"] description = "Validate the monitoring example configuration" run = "deno run -A examples/monitoring/validate.ts" diff --git a/packages/adonisjs/package.json b/packages/adonisjs/package.json index 345e166c2..dfc0a18c4 100644 --- a/packages/adonisjs/package.json +++ b/packages/adonisjs/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/adonisjs", - "version": "2.4.0", + "version": "2.5.0", "description": "Integration package for Fedify with AdonisJS", "keywords": [ "Fedify", diff --git a/packages/amqp/deno.json b/packages/amqp/deno.json index a258c5111..8f7f48f93 100644 --- a/packages/amqp/deno.json +++ b/packages/amqp/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/amqp", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "exports": { ".": "./src/mod.ts", diff --git a/packages/amqp/package.json b/packages/amqp/package.json index 160983fdf..57d71b953 100644 --- a/packages/amqp/package.json +++ b/packages/amqp/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/amqp", - "version": "2.4.0", + "version": "2.5.0", "description": "AMQP/RabbitMQ driver for Fedify", "keywords": [ "fedify", diff --git a/packages/astro/deno.json b/packages/astro/deno.json index 962ca0b94..747c8aae5 100644 --- a/packages/astro/deno.json +++ b/packages/astro/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/astro", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "imports": {}, "exports": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 47fa046d5..5dfafa064 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/astro", - "version": "2.4.0", + "version": "2.5.0", "description": "Integrate Fedify with Astro", "keywords": [ "Fedify", diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 95ceb06b1..1fcf784ac 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -65,6 +65,14 @@ for await ( The seed object itself is not yielded. If it appears in the discovered collection, it is skipped by ID. +Context and `replies` collections support inline items and pagination through +`first` and successive `next` links. A collection page used as the starting +collection also follows `next`. Pages are loaded as needed, and traversal +stops on repeated page links. Pagination does not increase reply-tree depth. +If a page cannot be loaded, items already yielded are retained and later +configured strategies can continue unless cancellation or traversal limits +stop them. + Configured strategies run in order. They share `maxItems`, `maxRequests`, abort state, and object ID deduplication; if two strategies discover the same object, the first strategy keeps its `BackfillItem` metadata. @@ -133,7 +141,7 @@ All configured strategies share the same traversal controls: - `maxItems` limits the number of yielded objects. Skipped duplicates do not count. - `maxRequests` limits calls to `documentLoader`. Embedded objects and - collections do not count. + collections, including embedded pages, do not count. - `maxDepth` limits reply-tree traversal and defaults to 10. It does not limit context collection items. - `interval` adds a delay between loader requests. Its callback receives @@ -145,7 +153,11 @@ An `interval` string requires the global `Temporal` API or a polyfill. If the seed has no context, or its context resolves to a non-collection, context strategies yield nothing. Loader failures are skipped unless -traversal is aborted. +traversal is aborted. If the context collection cannot be loaded, later +configured strategies can still run. Failed loads consume a request, and +later strategies can use embedded data even when the request budget is +exhausted. Configuration errors, such as an invalid `interval`, still +propagate. Dereferenced documents are cached in memory for one `backfill()` traversal. Applications that need persistent or shared caching can provide it through diff --git a/packages/backfill/deno.json b/packages/backfill/deno.json index f7986eae7..7168c6a1b 100644 --- a/packages/backfill/deno.json +++ b/packages/backfill/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/backfill", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "exports": { ".": "./src/mod.ts" diff --git a/packages/backfill/package.json b/packages/backfill/package.json index 4e4c4c4fb..e8aed9ca4 100644 --- a/packages/backfill/package.json +++ b/packages/backfill/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/backfill", - "version": "2.4.0", + "version": "2.5.0", "description": "ActivityPub backfill support for Fedify", "keywords": [ "Fedify", diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 3875eea7e..1a028e603 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1,7 +1,15 @@ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import test, { describe } from "node:test"; import { backfill, type BackfillContext, MaxRequestsExceeded } from "./mod.ts"; -import { Announce, Collection, Create, Note } from "@fedify/vocab"; +import { + Announce, + Collection, + CollectionPage, + Create, + Note, + OrderedCollection, + OrderedCollectionPage, +} from "@fedify/vocab"; async function collect( context: BackfillContext, @@ -1772,3 +1780,415 @@ describe("backfill", () => { deepStrictEqual(iterations, [0, 1]); }); }); + +describe("collection pagination", () => { + const iri = (name: string) => new URL(`https://example.com/${name}`); + + for (const ordered of [false, true]) { + for (const replies of [false, true]) { + for (const embedded of [false, true]) { + test(`${ordered ? "ordered" : "unordered"} ${replies ? "replies" : "context"} with ${embedded ? "embedded" : "linked"} pages`, async () => { + const CollectionType = ordered ? OrderedCollection : Collection; + const PageType = ordered ? OrderedCollectionPage : CollectionPage; + const a = new Note({ id: iri("a") }); + const b = new Note({ id: iri("b") }); + const second = new PageType({ id: iri("page-2"), items: [b] }); + const first = new PageType({ + id: iri("page-1"), + items: [a], + next: embedded ? second : second.id, + }); + const collection = new CollectionType({ + id: iri("thread"), + first: embedded ? first : first.id, + }); + const seed = new Note({ + id: iri("seed"), + contexts: replies ? [] : [iri("thread")], + replies: replies ? collection : null, + }); + const documents = new Map([ + [iri("thread").href, collection], + [iri("page-1").href, first], + [iri("page-2").href, second], + ]); + const calls: string[] = []; + const context: BackfillContext = { + documentLoader: (url) => { + calls.push(url.href); + return Promise.resolve(documents.get(url.href) ?? null); + }, + }; + const items = await collect(context, seed, { + strategies: replies ? ["reply-tree"] : ["context-auto"], + maxDepth: 1, + maxRequests: embedded ? (replies ? 0 : 1) : 3, + }); + deepStrictEqual(items.map((item) => item.id?.href), [ + a.id?.href, + b.id?.href, + ]); + deepStrictEqual( + items.map((item) => item.depth), + replies ? [1, 1] : [0, 0], + ); + strictEqual( + calls.length, + embedded ? (replies ? 0 : 1) : (replies ? 2 : 3), + ); + }); + } + } + } + + test("starting page follows next and stops before loading a repeated page", async () => { + const first = new OrderedCollectionPage({ + id: iri("page-1"), + items: [new Note({ id: iri("a") })], + next: iri("page-2"), + }); + const second = new OrderedCollectionPage({ + id: iri("page-2"), + items: [new Note({ id: iri("b") })], + next: first.id, + }); + const calls: string[] = []; + const items = await collect({ + documentLoader: (url) => { + calls.push(url.href); + return Promise.resolve(url.href === first.id?.href ? first : second); + }, + }, new Note({ contexts: [first.id!] })); + deepStrictEqual(items.map((item) => item.id?.href), [ + iri("a").href, + iri("b").href, + ]); + deepStrictEqual(calls, [first.id?.href, second.id?.href]); + }); + + for (const limit of ["maxItems", "maxRequests"] as const) { + test(`${limit} stops between pages`, async () => { + const first = new CollectionPage({ + id: iri("page-1"), + items: [new Note({ id: iri("a") })], + next: iri("page-2"), + }); + const calls: string[] = []; + const items = await collect( + { + documentLoader: (url) => { + calls.push(url.href); + return Promise.resolve(first); + }, + }, + new Note({ + contexts: [first.id!], + replies: new Collection({ + items: [new Note({ id: iri("fallback") })], + }), + }), + { [limit]: 1, strategies: ["context-auto", "reply-tree"] }, + ); + deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]); + deepStrictEqual(calls, [first.id?.href]); + }); + } + + for (const failure of ["missing", "throw", "invalid"] as const) { + test(`${failure} page retains items and allows the next strategy`, async () => { + const seed = new Note({ + contexts: [iri("thread")], + replies: new Collection({ items: [new Note({ id: iri("reply") })] }), + }); + const first = new CollectionPage({ + items: [new Note({ id: iri("a") })], + next: iri("broken"), + }); + const items = await collect( + { + documentLoader: (url) => { + if (url.href === iri("thread").href) { + return Promise.resolve(new Collection({ first })); + } + if (failure === "throw") throw new Error("page failed"); + return Promise.resolve(failure === "missing" ? null : new Note({})); + }, + }, + seed, + { strategies: ["context-auto", "reply-tree"] }, + ); + deepStrictEqual(items.map((item) => item.id?.href), [ + iri("a").href, + iri("reply").href, + ]); + }); + } + + test("inline items precede page items", async () => { + const items = await collect({ + documentLoader: () => + Promise.resolve( + new Collection({ + items: [new Note({ id: iri("inline") })], + first: new CollectionPage({ + items: [new Note({ id: iri("paged") })], + }), + }), + ), + }, new Note({ contexts: [iri("thread")] })); + deepStrictEqual(items.map((item) => item.id?.href), [ + iri("inline").href, + iri("paged").href, + ]); + }); + + test("cancellation during a page load prevents later strategies", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled"); + const seed = new Note({ + contexts: [iri("thread")], + replies: new Collection({ items: [new Note({ id: iri("fallback") })] }), + }); + await rejects( + collect( + { + documentLoader: (url, options) => { + strictEqual(options?.signal, controller.signal); + if (url.href === iri("thread").href) { + return Promise.resolve(new Collection({ first: iri("page") })); + } + controller.abort(reason); + throw reason; + }, + }, + seed, + { + signal: controller.signal, + strategies: ["context-auto", "reply-tree"], + }, + ), + (error) => error === reason, + ); + }); +}); + +test("page loads share the cache and interval across strategies", async () => { + const iri = (name: string) => new URL(`https://example.com/${name}`); + const collection = new Collection({ first: iri("page") }); + const page = new CollectionPage({ items: [new Note({ id: iri("a") })] }); + const seed = new Note({ + contexts: [iri("thread")], + replies: new Collection({ first: iri("page") }), + }); + const calls: string[] = []; + const intervals: number[] = []; + const items = await collect( + { + documentLoader: (url) => { + calls.push(url.href); + return Promise.resolve( + url.href === iri("thread").href ? collection : page, + ); + }, + }, + seed, + { + strategies: ["context-auto", "reply-tree"], + maxRequests: 2, + interval: (count) => { + intervals.push(count); + return { milliseconds: 0 }; + }, + }, + ); + deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]); + deepStrictEqual(calls, [iri("thread").href, iri("page").href]); + deepStrictEqual(intervals, [0, 1]); +}); + +test("embedded pages without IDs follow next without loader calls", async () => { + const a = new Note({ id: new URL("https://example.com/a") }); + const b = new Note({ id: new URL("https://example.com/b") }); + const seed = new Note({ + replies: new Collection({ + first: new CollectionPage({ + items: [a], + next: new CollectionPage({ items: [b] }), + }), + }), + }); + const items = await collect( + { + documentLoader: () => { + throw new Error("embedded pages must not be fetched"); + }, + }, + seed, + { strategies: ["reply-tree"], maxRequests: 0, maxDepth: 1 }, + ); + deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]); +}); + +describe("context loader failures", () => { + const contextId = new URL("https://example.com/thread"); + const parent = new Note({ id: new URL("https://example.com/parent") }); + + for (const synchronous of [false, true]) { + const failure = synchronous ? "synchronous throw" : "promise rejection"; + const fail = () => { + const error = new Error("Context collection unavailable"); + if (synchronous) throw error; + return Promise.reject(error); + }; + + test(`${failure} allows reply-tree to yield an embedded parent`, async () => { + const seed = new Note({ contexts: [contextId], replyTarget: parent }); + let requests = 0; + const items = await collect( + { + documentLoader: () => { + requests++; + return fail(); + }, + }, + seed, + { strategies: ["context-auto", "reply-tree"], maxRequests: 1 }, + ); + deepStrictEqual(items.map((item) => item.object), [parent]); + strictEqual(items[0].strategy, "reply-tree"); + strictEqual(requests, 1); + }); + + test(`${failure} finishes the default strategy without items`, async () => { + const seed = new Note({ contexts: [contextId] }); + deepStrictEqual(await collect({ documentLoader: fail }, seed), []); + }); + + test(`${failure} consumes the request budget`, async () => { + const seed = new Note({ contexts: [contextId], replyTarget: parent.id }); + const requests: string[] = []; + const items = await collect( + { + documentLoader: (url) => { + requests.push(url.href); + if (url.href === contextId.href) return fail(); + return Promise.resolve(parent); + }, + }, + seed, + { strategies: ["context-auto", "reply-tree"], maxRequests: 1 }, + ); + deepStrictEqual(items, []); + deepStrictEqual(requests, [contextId.href]); + }); + + test(`${failure} propagates the cancellation reason`, async () => { + const controller = new AbortController(); + const reason = new Error("Stop backfill"); + const seed = new Note({ contexts: [contextId], replyTarget: parent }); + const yielded: unknown[] = []; + await rejects(async () => { + for await ( + const item of backfill( + { + documentLoader: (_url, options) => { + strictEqual(options?.signal, controller.signal); + controller.abort(reason); + return fail(); + }, + }, + seed, + { + strategies: ["context-auto", "reply-tree"], + signal: controller.signal, + }, + ) + ) yielded.push(item); + }, (error) => error === reason); + deepStrictEqual(yielded, []); + }); + } + + test("a failed context load is not cached for later reply-tree loads", async () => { + const seed = new Note({ contexts: [contextId], replyTarget: contextId }); + let requests = 0; + const items = await collect( + { + documentLoader: (url) => { + strictEqual(url.href, contextId.href); + requests++; + if (requests === 1) { + return Promise.reject( + new Error("Temporary failure"), + ); + } + return Promise.resolve(parent); + }, + }, + seed, + { strategies: ["context-auto", "reply-tree"], maxRequests: 2 }, + ); + deepStrictEqual(items.map((item) => item.object.id?.href), [ + parent.id?.href, + ]); + strictEqual(requests, 2); + }); + + test("an exhausted budget still allows embedded reply-tree data", async () => { + const seed = new Note({ contexts: [contextId], replyTarget: parent }); + const items = await collect( + { + documentLoader: () => { + throw new Error("No requests allowed"); + }, + }, + seed, + { strategies: ["context-auto", "reply-tree"], maxRequests: 0 }, + ); + deepStrictEqual(items.map((item) => item.object), [parent]); + }); + + test("interval callback errors propagate before the loader is called", async () => { + const reason = new Error("Invalid interval configuration"); + let requests = 0; + const seed = new Note({ contexts: [contextId], replyTarget: parent }); + await rejects( + collect( + { + documentLoader: () => { + requests++; + return Promise.resolve(null); + }, + }, + seed, + { + strategies: ["context-auto", "reply-tree"], + interval: () => { + throw reason; + }, + }, + ), + (error) => error === reason, + ); + strictEqual(requests, 0); + }); + + test("invalid interval strings still propagate", async () => { + let requests = 0; + const seed = new Note({ contexts: [contextId], replyTarget: parent }); + await rejects(collect( + { + documentLoader: () => { + requests++; + return Promise.resolve(null); + }, + }, + seed, + { + strategies: ["context-auto", "reply-tree"], + interval: "not a duration", + }, + )); + strictEqual(requests, 0); + }); +}); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index a836f4c4a..c19488e50 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -200,7 +200,9 @@ async function* getContextStrategyItems( }> { const contextId = note.contextIds[0]; if (contextId == null) return; - const collection = await loadObject(context, contextId, options, budget); + const collection = await loadObject(context, contextId, options, budget, { + skipLoaderErrors: true, + }); if (!isCollection(collection)) return; for await ( const object of getCollectionItems( @@ -517,8 +519,8 @@ async function* getCollectionItems( budget: RequestBudget, skipIds?: ReadonlySet, ): AsyncIterable { - yield* collection.getItems({ - documentLoader: async (url) => { + const itemOptions = { + documentLoader: async (url: string) => { return await loadCollectionItemDocument( context, url, @@ -527,8 +529,61 @@ async function* getCollectionItems( skipIds, ); }, - crossOrigin: "trust", - }); + crossOrigin: "trust" as const, + }; + const pageOptions = { + documentLoader: async (url: string) => { + const object = await loadObject( + context, + new URL(url), + options, + budget, + { throwOnBudgetExceeded: true }, + ); + if (object == null) throw new Error(`Collection page not found: ${url}`); + return { + contextUrl: null, + documentUrl: url, + document: await object.toJsonLd(), + }; + }, + crossOrigin: "trust" as const, + }; + const visitedIds = new Set(); + const visitedPages = new WeakSet(); + let current: BackfillCollection | null = collection; + while (current != null) { + budget.signal?.throwIfAborted(); + if (visitedPages.has(current)) return; + if (current.id != null) { + if (visitedIds.has(current.id.href)) return; + visitedIds.add(current.id.href); + } + visitedPages.add(current); + yield* current.getItems(itemOptions); + + const page: CollectionPage | OrderedCollectionPage | null = + current instanceof CollectionPage || + current instanceof OrderedCollectionPage + ? current + : null; + const pageId: URL | null = page == null ? current.firstId : page.nextId; + if (pageId != null && visitedIds.has(pageId.href)) return; + try { + budget.signal?.throwIfAborted(); + current = page == null + ? await current.getFirst(pageOptions) + : await page.getNext(pageOptions); + } catch (error) { + if (error instanceof MaxRequestsExceeded) throw error; + budget.signal?.throwIfAborted(); + return; + } + // Remember the requested IRI too, in case the page has a different ID. + if (pageId != null && pageId.href !== current?.id?.href) { + visitedIds.add(pageId.href); + } + } } async function getCreateActivityObject( @@ -568,7 +623,7 @@ async function loadCollectionItemDocument( iri, options, budget, - true, + { throwOnBudgetExceeded: true }, ); } catch (error) { if (error instanceof MaxRequestsExceeded) throw error; @@ -599,7 +654,13 @@ async function loadObject( iri: URL, options: BackfillOptions, budget: RequestBudget, - throwOnBudgetExceeded = false, + { + throwOnBudgetExceeded = false, + skipLoaderErrors = false, + }: { + throwOnBudgetExceeded?: boolean; + skipLoaderErrors?: boolean; + } = {}, ): Promise { budget.signal?.throwIfAborted(); const cacheKey = iri.href; @@ -618,14 +679,19 @@ async function loadObject( budget.signal?.throwIfAborted(); budget.requestCount++; - const document = context.documentLoader(iri, { signal: budget.signal }); - budget.documents.set(cacheKey, document); + let document: Promise | undefined; try { + document = context.documentLoader(iri, { signal: budget.signal }); + budget.documents.set(cacheKey, document); return await document; } catch (error) { if (budget.documents.get(cacheKey) === document) { budget.documents.delete(cacheKey); } + if (skipLoaderErrors) { + budget.signal?.throwIfAborted(); + return null; + } throw error; } } diff --git a/packages/cfworkers/deno.json b/packages/cfworkers/deno.json index d5ac79828..5bcafee7c 100644 --- a/packages/cfworkers/deno.json +++ b/packages/cfworkers/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/cfworkers", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "imports": { "@cloudflare/workers-types/experimental": "npm:@cloudflare/workers-types@^4.20260511.1/experimental" diff --git a/packages/cfworkers/package.json b/packages/cfworkers/package.json index 22400c114..4e102e132 100644 --- a/packages/cfworkers/package.json +++ b/packages/cfworkers/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/cfworkers", - "version": "2.4.0", + "version": "2.5.0", "description": "Adapt Fedify with Cloudflare Workers", "keywords": [ "Fedify", @@ -49,10 +49,12 @@ ], "peerDependencies": { "@fedify/fedify": "workspace:^", - "@cloudflare/workers-types": "catalog:" + "@cloudflare/workers-types": "^4.20260511.1 || ^5.0.0" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", + "@cloudflare/workers-types": "catalog:", + "semver": "^7.7.2", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "~3.2.0", @@ -64,6 +66,6 @@ "prepack": "pnpm build", "prepublish": "pnpm build", "pretest": "pnpm build", - "test": "tsc -p test/typecheck/tsconfig.json --noEmit && vitest run" + "test": "tsc -p test/typecheck/tsconfig.json --noEmit && node --test test/peer-dependencies.check.mjs && vitest run" } } diff --git a/packages/cfworkers/test/mq.test.ts b/packages/cfworkers/test/mq.test.ts index a46124493..8ca715918 100644 --- a/packages/cfworkers/test/mq.test.ts +++ b/packages/cfworkers/test/mq.test.ts @@ -229,6 +229,87 @@ describe("WorkersMessageQueue", () => { sendBatchSpy.mockRestore(); }); + describe("message wrapper round trip", () => { + it("round-trips a message through enqueue() and processMessage()", async () => { + const sendSpy = vi + .spyOn(env.Q1, "send") + .mockImplementation(async () => {}); + const getSpy = vi.spyOn(env.KV1, "get").mockResolvedValue(null); + const putSpy = vi.spyOn(env.KV1, "put").mockResolvedValue(undefined); + const deleteSpy = vi.spyOn(env.KV1, "delete").mockResolvedValue(undefined); + + const queue = new WorkersMessageQueue(env.Q1, { orderingKv: env.KV1 }); + const payload = { foo: 1 }; + await queue.enqueue(payload, { orderingKey: "key1" }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + + const requestMessage = sendSpy.mock.calls[0][0]; + + const result = await queue.processMessage(requestMessage); + + expect(result.shouldProcess).toBe(true); + expect(result.message).toEqual(payload); + expect(result.release).toBeDefined(); + + expect(getSpy).toHaveBeenCalledWith("__fedify_ordering_key1"); + expect(putSpy).toHaveBeenCalledWith( + "__fedify_ordering_key1", + expect.any(String), + { expirationTtl: 60 }, + ); + + // Release the lock + await result.release!(); + expect(deleteSpy).toHaveBeenCalledWith("__fedify_ordering_key1"); + + sendSpy.mockRestore(); + getSpy.mockRestore(); + putSpy.mockRestore(); + deleteSpy.mockRestore(); + }); + + it("round-trips a message through enqueueMany() and processMessage()", async () => { + const sendBatchSpy = vi + .spyOn(env.Q1, "sendBatch") + .mockImplementation(async () => {}); + const getSpy = vi.spyOn(env.KV1, "get").mockResolvedValue(null); + const putSpy = vi.spyOn(env.KV1, "put").mockResolvedValue(undefined); + const deleteSpy = vi.spyOn(env.KV1, "delete").mockResolvedValue(undefined); + + const queue = new WorkersMessageQueue(env.Q1, { orderingKv: env.KV1 }); + const payloads = [{ a: 1 }, { b: 2 }]; + await queue.enqueueMany(payloads, { orderingKey: "batch-key" }); + + expect(sendBatchSpy).toHaveBeenCalledTimes(1); + + const requests = sendBatchSpy.mock.calls[0][0]; + const firstRequestMessage = requests[0].body; + + const result = await queue.processMessage(firstRequestMessage); + + expect(result.shouldProcess).toBe(true); + expect(result.message).toEqual(payloads[0]); + expect(result.release).toBeDefined(); + + expect(getSpy).toHaveBeenCalledWith("__fedify_ordering_batch-key"); + expect(putSpy).toHaveBeenCalledWith( + "__fedify_ordering_batch-key", + expect.any(String), + { expirationTtl: 60 }, + ); + + // Release the lock + await result.release!(); + expect(deleteSpy).toHaveBeenCalledWith("__fedify_ordering_batch-key"); + + sendBatchSpy.mockRestore(); + getSpy.mockRestore(); + putSpy.mockRestore(); + deleteSpy.mockRestore(); + }); + }); + it("listen() throws TypeError", () => { const queue = new WorkersMessageQueue(env.Q1); expect(() => queue.listen(() => {})).toThrow(TypeError); diff --git a/packages/cfworkers/test/peer-dependencies.check.mjs b/packages/cfworkers/test/peer-dependencies.check.mjs new file mode 100644 index 000000000..40413e93c --- /dev/null +++ b/packages/cfworkers/test/peer-dependencies.check.mjs @@ -0,0 +1,56 @@ +// Checks the published peer dependency range on `@cloudflare/workers-types`. +// npm refuses to install packages whose peer ranges do not overlap, so the +// range has to accept every major version users can reasonably install +// alongside current Wrangler releases. See also: +// https://github.com/fedify-dev/fedify/issues/1255 +import { ok } from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import semver from "semver"; + +const PACKAGE_NAME = "@cloudflare/workers-types"; + +async function getPeerRange() { + const manifest = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf-8"), + ); + const range = manifest.peerDependencies[PACKAGE_NAME]; + if (range !== "catalog:") return range; + // pnpm replaces `catalog:` with the range from the default catalog when + // the package is published, so resolve it the same way: + const workspace = await readFile( + new URL("../../../pnpm-workspace.yaml", import.meta.url), + "utf-8", + ); + const pattern = new RegExp( + `^\\s+["']?${PACKAGE_NAME.replaceAll("/", "\\/")}["']?:\\s*(.+?)\\s*$`, + "m", + ); + const match = workspace.match(pattern); + ok(match != null, `${PACKAGE_NAME} is not found in the pnpm catalog.`); + return match[1].replace(/^(["'])(.*)\1$/, "$2"); +} + +test(`peer dependency on ${PACKAGE_NAME}`, async () => { + const range = await getPeerRange(); + ok(semver.validRange(range) != null, `Invalid range: ${range}`); + for ( + const version of [ + "4.20260511.1", // The minimum version that we support. + "4.20260702.1", // The last v4 release. + "5.20260703.1", // The first v5 release. + "5.20261006.1", + ] + ) { + ok( + semver.satisfies(version, range), + `${PACKAGE_NAME}@${version} should satisfy ${range}.`, + ); + } + for (const version of ["4.20260511.0", "6.0.0"]) { + ok( + !semver.satisfies(version, range), + `${PACKAGE_NAME}@${version} should not satisfy ${range}.`, + ); + } +}); diff --git a/packages/cfworkers/test/typecheck/workers-types.ts b/packages/cfworkers/test/typecheck/workers-types.ts new file mode 100644 index 000000000..32c0b6297 --- /dev/null +++ b/packages/cfworkers/test/typecheck/workers-types.ts @@ -0,0 +1,16 @@ +import type { KVNamespace, Queue } from "@cloudflare/workers-types"; +import type { KvStore, MessageQueue } from "@fedify/fedify/federation"; +import { WorkersKvStore, WorkersMessageQueue } from "../../dist/mod.js"; + +// Checks that the public API accepts the KV and Queue bindings declared by +// `@cloudflare/workers-types` itself, in addition to the `wrangler types` +// declarations covered by wrangler-generated-kv.ts. + +declare const kv: KVNamespace; +declare const queue: Queue; + +export const store: KvStore = new WorkersKvStore(kv); +export const mq: MessageQueue = new WorkersMessageQueue(queue); +export const orderedMq: MessageQueue = new WorkersMessageQueue(queue, { + orderingKv: kv, +}); diff --git a/packages/cli/deno.json b/packages/cli/deno.json index 0847d4340..e6478bd66 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/cli", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e6e57ed8..ef4c2350f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/cli", - "version": "2.4.0", + "version": "2.5.0", "type": "module", "files": [ "README.md", diff --git a/packages/cli/src/bench/scenarios/failure.test.ts b/packages/cli/src/bench/scenarios/failure.test.ts index 17a83ef36..129774e32 100644 --- a/packages/cli/src/bench/scenarios/failure.test.ts +++ b/packages/cli/src/bench/scenarios/failure.test.ts @@ -27,7 +27,7 @@ for ( type: "failure", fault, sender: "alice", - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "25ms", queueDrainTimeout: "1s", }], @@ -62,12 +62,13 @@ for ( return Promise.resolve(new Response("unexpected", { status: 500 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 3); assert.strictEqual(measurement.requests.failed, 0); assert.strictEqual(measurement.requests.successRate, 1); - assert.ok(triggerCalls > 0); + assert.strictEqual(triggerCalls, 3); assert.strictEqual(triggerRecipientCount, 1); }); } @@ -84,7 +85,7 @@ test("failureRunner - uses configured sink base for remote faults", async () => fault: "remote-404", sender: "alice", sinkBase, - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "25ms", queueDrainTimeout: "1s", }], @@ -118,9 +119,12 @@ test("failureRunner - uses configured sink base for remote faults", async () => return Promise.resolve(new Response("unexpected", { status: 500 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 3); + assert.strictEqual(measurement.requests.failed, 0); + assert.strictEqual(triggerCalls, 3); assert.strictEqual(recipientInbox, new URL("/inbox/0", sinkBase).href); }); @@ -190,14 +194,6 @@ test("failureRunner - shares sink base across remote fault mix", async () => { queueDrainTimeout: "1s", }], }).scenarios[0]; - let now = 0; - const clock: Clock = { - now: () => now, - sleepUntil: (timeMs) => { - now = Math.max(now, timeMs); - return Promise.resolve(); - }, - }; let triggerCalls = 0; const recipientInboxes: string[] = []; @@ -227,14 +223,17 @@ test("failureRunner - shares sink base across remote fault mix", async () => { return Promise.resolve(new Response("unexpected", { status: 500 })); }, assertDestinationAllowed: () => {}, - clock, + clock: createTestClock(), }); assert.strictEqual(measurement.requests.total, 3); assert.strictEqual(measurement.requests.total, triggerCalls); assert.strictEqual(measurement.requests.failed, 0); - assert.ok(recipientInboxes.includes(new URL("/inbox/0", sinkBase).href)); - assert.ok(recipientInboxes.includes(new URL("/inbox/1", sinkBase).href)); + assert.deepStrictEqual(recipientInboxes, [ + new URL("/inbox/0", sinkBase).href, + new URL("/inbox/1", sinkBase).href, + new URL("/inbox/0", sinkBase).href, + ]); }); test("failureRunner.validate - rejects sinkBase for mixed network faults", () => { @@ -266,7 +265,7 @@ test("failureRunner - detects network-error retries", async () => { type: "failure", fault: "network-error", sender: "alice", - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "25ms", queueDrainTimeout: "50ms", }], @@ -296,12 +295,13 @@ test("failureRunner - detects network-error retries", async () => { return Promise.resolve(new Response("unexpected", { status: 500 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 3); assert.strictEqual(measurement.requests.failed, 0); assert.strictEqual(measurement.requests.successRate, 1); - assert.ok(triggerCalls > 0); + assert.strictEqual(triggerCalls, 3); }); test("failureRunner - tolerates transient remote fault stats failures", async () => { @@ -314,7 +314,7 @@ test("failureRunner - tolerates transient remote fault stats failures", async () type: "failure", fault: "remote-404", sender: "alice", - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "25ms", queueDrainTimeout: "1s", }], @@ -353,12 +353,15 @@ test("failureRunner - tolerates transient remote fault stats failures", async () return Promise.resolve(new Response("unexpected", { status: 500 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 3); assert.strictEqual(measurement.requests.failed, 0); assert.strictEqual(measurement.requests.successRate, 1); - assert.ok(statsCalls >= 3); + assert.strictEqual(triggerCalls, 3); + // The first send reads a baseline and two polls; later sends need one poll. + assert.strictEqual(statsCalls, 7); }); test("failureRunner - uses abortable remote fault poll sleeps", async () => { @@ -512,14 +515,6 @@ test("failureRunner - discovers inbound failure inboxes once", async () => { duration: "30ms", }], }).scenarios[0]; - let now = 0; - const clock: Clock = { - now: () => now, - sleepUntil: (timeMs) => { - now = Math.max(now, timeMs); - return Promise.resolve(); - }, - }; let malformedSignatureRequests = 0; let signedDateRequests = 0; const measurement = await failureRunner.run({ @@ -552,7 +547,7 @@ test("failureRunner - discovers inbound failure inboxes once", async () => { return new Response("not found", { status: 404 }); }, assertDestinationAllowed: () => {}, - clock, + clock: createTestClock(), }); assert.strictEqual(measurement.requests.total, 3); @@ -605,7 +600,7 @@ test("failureRunner - treats inbound 5xx as target failures", async () => { type: "failure", fault: "invalid-signature", recipient: new URL("/users/alice", target).href, - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "50ms", }], }).scenarios[0]; @@ -626,11 +621,15 @@ test("failureRunner - treats inbound 5xx as target failures", async () => { return Promise.resolve(new Response("not found", { status: 404 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 5); + assert.strictEqual(measurement.requests.failed, 5); assert.strictEqual(measurement.requests.successRate, 0); - assert.ok(measurement.errors.some((e) => e.status === 500)); + assert.strictEqual(measurement.errors.length, 1); + assert.strictEqual(measurement.errors[0].status, 500); + assert.strictEqual(measurement.errors[0].count, 5); } finally { try { await fleet?.close(); @@ -676,7 +675,7 @@ test("failureRunner - treats unexpected inbound 4xx as target failures", async ( type: "failure", fault: "invalid-signature", recipient: new URL("/users/alice", target).href, - load: { concurrency: 1 }, + load: { rate: 100 }, duration: "50ms", }], }).scenarios[0]; @@ -697,11 +696,15 @@ test("failureRunner - treats unexpected inbound 4xx as target failures", async ( return Promise.resolve(new Response("not found", { status: 404 })); }, assertDestinationAllowed: () => {}, + clock: createTestClock(), }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 5); + assert.strictEqual(measurement.requests.failed, 5); assert.strictEqual(measurement.requests.successRate, 0); - assert.ok(measurement.errors.some((e) => e.status === 404)); + assert.strictEqual(measurement.errors.length, 1); + assert.strictEqual(measurement.errors[0].status, 404); + assert.strictEqual(measurement.errors[0].count, 5); } finally { try { await fleet?.close(); @@ -711,6 +714,18 @@ test("failureRunner - treats unexpected inbound 4xx as target failures", async ( } }); +// Advance only on scheduled sleeps, so host scheduling cannot expire a load window. +function createTestClock(): Clock { + let now = 0; + return { + now: () => now, + sleepUntil: (timeMs) => { + now = Math.max(now, timeMs); + return Promise.resolve(); + }, + }; +} + function json(body: unknown): Response { return new Response(JSON.stringify(body), { headers: { "content-type": "application/activity+json" }, diff --git a/packages/cli/src/bench/scenarios/fanout.test.ts b/packages/cli/src/bench/scenarios/fanout.test.ts index 070dd4717..fd0c886ca 100644 --- a/packages/cli/src/bench/scenarios/fanout.test.ts +++ b/packages/cli/src/bench/scenarios/fanout.test.ts @@ -260,6 +260,16 @@ test("fanoutRunner - waits for observed queue work before drain", async () => { test("fanoutRunner - ignores baseline backlog completions while draining", async () => { const target = new URL("http://target.test/"); let triggerCalls = 0; + let now = 0; + // Keep the 1 ms load window open until the first request starts, even + // when the test runner is busy. Drain polling advances the clock. + const clock: Clock = { + now: () => now, + sleepUntil: (timeMs) => { + now = Math.max(now, timeMs); + return Promise.resolve(); + }, + }; const scenario = normalizeSuite({ version: 1, target: target.href, @@ -281,6 +291,7 @@ test("fanoutRunner - ignores baseline backlog completions while draining", async contextLoader: await getContextLoader({ allowPrivateAddress: true }), allowPrivateAddress: true, fleet: null, + clock, fetch: (input) => { const url = new URL(input instanceof Request ? input.url : input); if (url.pathname === "/.well-known/fedify/bench/stats") { @@ -298,9 +309,10 @@ test("fanoutRunner - ignores baseline backlog completions while draining", async }, }); - assert.ok(measurement.requests.total > 0); + assert.strictEqual(measurement.requests.total, 1); + assert.strictEqual(measurement.requests.failed, 1); assert.strictEqual(measurement.requests.successRate, 0); - assert.ok(triggerCalls > 0); + assert.strictEqual(triggerCalls, 1); }); test("fanoutRunner - tolerates transient drain stats failures", async () => { diff --git a/packages/cli/src/bench/scenarios/inbox.test.ts b/packages/cli/src/bench/scenarios/inbox.test.ts index 57df0e970..574f4fd98 100644 --- a/packages/cli/src/bench/scenarios/inbox.test.ts +++ b/packages/cli/src/bench/scenarios/inbox.test.ts @@ -9,6 +9,7 @@ import test from "node:test"; import { serve } from "srvx"; import { getContextLoader, getDocumentLoader } from "../../docloader.ts"; import { buildFleet } from "../actor/fleet.ts"; +import type { Clock } from "../load/clock.ts"; import { normalizeSuite } from "../scenario/normalize.ts"; import type { Suite } from "../scenario/types.ts"; import { spawnSyntheticServer } from "../server/synthetic.ts"; @@ -60,16 +61,17 @@ async function spawnBenchmarkTarget(usernames: string[] = ["alice"]) { received++; }); - // Record every inbox path that was POSTed to, so a test can confirm that - // deliveries were spread across multiple recipients' personal inboxes. - const inboxHits = new Set(); + // Count POSTs per inbox path, so a test can confirm how deliveries were + // allocated across multiple recipients' personal inboxes. + const inboxHits = new Map(); const server = serve({ port: 0, hostname: "127.0.0.1", silent: true, fetch: (request: Request) => { if (request.method === "POST") { - inboxHits.add(new URL(request.url).pathname); + const path = new URL(request.url).pathname; + inboxHits.set(path, (inboxHits.get(path) ?? 0) + 1); } return federation.fetch(request, { contextData: undefined }); }, @@ -83,6 +85,19 @@ async function spawnBenchmarkTarget(usernames: string[] = ["alice"]) { }; } +// A clock that jumps straight to each scheduled arrival, so open-loop runs +// make a fixed number of attempts no matter how slow the deliveries are. +function createFakeClock(): Clock { + let now = 0; + return { + now: () => now, + sleepUntil: (timeMs) => { + now = Math.max(now, timeMs); + return Promise.resolve(); + }, + }; +} + test("inboxRunner - signed deliveries verify against a benchmarkMode target", async () => { const target = await spawnBenchmarkTarget(); let fleet: Awaited> | undefined; @@ -142,57 +157,80 @@ test("inboxRunner - signed deliveries verify against a benchmarkMode target", as } }); -test("inboxRunner - reports server metrics scoped past the warm-up", async () => { - const target = await spawnBenchmarkTarget(); - let fleet: Awaited> | undefined; - try { - fleet = await spawnSyntheticServer( - await buildFleet([{ - count: 1, - signatureStandards: ["draft-cavage-http-signatures-12"], - }]), - ); - const suite: Suite = { - version: 1, - target: target.url.href, - scenarios: [{ - name: "inbox-warmup", - type: "inbox", - recipient: new URL("/users/alice", target.url).href, - inbox: "shared", - load: { concurrency: 2 }, - // A non-zero warm-up exercises the measured-window baseline snapshot. - warmup: "120ms", - duration: "400ms", - }], - }; - const scenario = normalizeSuite(suite).scenarios[0]; - const measurement = await inboxRunner.run({ - scenario, - target: target.url, - documentLoader: await getDocumentLoader({ allowPrivateAddress: true }), - contextLoader: await getContextLoader({ allowPrivateAddress: true }), - allowPrivateAddress: true, - fleet, - }); - - assert.strictEqual(measurement.requests.successRate, 1); - // The measured window verified signatures, so server metrics survive the - // baseline diff rather than being cancelled out by warm-up traffic. - assert.ok( - measurement.server?.signatureVerificationMs != null, - "expected windowed server signature-verification metrics", - ); - } finally { +test( + "inboxRunner - reports server metrics scoped past the warm-up", + { timeout: 60_000 }, + async () => { + const target = await spawnBenchmarkTarget(); + let fleet: Awaited> | undefined; try { - await fleet?.close(); + fleet = await spawnSyntheticServer( + await buildFleet([{ + count: 1, + signatureStandards: ["draft-cavage-http-signatures-12"], + }]), + ); + const suite: Suite = { + version: 1, + target: target.url.href, + scenarios: [{ + name: "inbox-warmup", + type: "inbox", + recipient: new URL("/users/alice", target.url).href, + inbox: "shared", + // 10/s over 400ms schedules exactly four arrivals: two inside the + // warm-up (0 and 100ms) and two measured (200 and 300ms), so the + // measured-window baseline snapshot is always taken. + load: { rate: 10, arrival: "constant", maxInFlight: 2 }, + warmup: "120ms", + duration: "400ms", + }], + }; + const scenario = normalizeSuite(suite).scenarios[0]; + const measurement = await inboxRunner.run({ + scenario, + target: target.url, + documentLoader: await getDocumentLoader({ allowPrivateAddress: true }), + contextLoader: await getContextLoader({ allowPrivateAddress: true }), + allowPrivateAddress: true, + fleet, + clock: createFakeClock(), + }); + + // Only the two measured deliveries are counted client-side, although all + // four, warm-up included, reached the inbox listener. + assert.strictEqual(measurement.requests.total, 2); + assert.strictEqual( + measurement.requests.successRate, + 1, + `expected all deliveries to succeed; errors: ${ + JSON.stringify(measurement.errors) + }`, + ); + assert.strictEqual(target.receivedCount(), 4); + // The measured window verified signatures, so server metrics survive the + // baseline diff rather than being cancelled out by warm-up traffic. + assert.ok( + measurement.server?.signatureVerificationMs != null, + "expected windowed server signature-verification metrics", + ); } finally { - await target.close(); + try { + await fleet?.close(); + } finally { + await target.close(); + } } - } -}); + }, +); -test("inboxRunner - rotates deliveries across multiple recipients", async () => { +// Recipients are allocated round-robin by signing index, so the run makes a +// fixed number of attempts rather than however many fit in a real-time window: +// four constant arrivals under a fake clock, each consuming one allocation +// index, split exactly two per recipient whatever order they complete in. +async function assertAllocatesAcrossRecipients( + signing: "jit" | "presign", +): Promise { const target = await spawnBenchmarkTarget(["alice", "bob"]); let fleet: Awaited> | undefined; try { @@ -214,8 +252,11 @@ test("inboxRunner - rotates deliveries across multiple recipients", async () => ], // Personal inboxes so each recipient's deliveries hit a distinct path. inbox: "personal", - load: { concurrency: 2 }, - duration: "300ms", + // 10/s over 400ms schedules exactly four arrivals (0, 100, 200, and + // 300ms), which is also the batch size presign signs up front. + load: { rate: 10, maxInFlight: 2 }, + duration: "400ms", + signing, }], }; const scenario = normalizeSuite(suite).scenarios[0]; @@ -226,8 +267,10 @@ test("inboxRunner - rotates deliveries across multiple recipients", async () => contextLoader: await getContextLoader({ allowPrivateAddress: true }), allowPrivateAddress: true, fleet, + clock: createFakeClock(), }); + assert.strictEqual(measurement.requests.total, 4); assert.strictEqual( measurement.requests.successRate, 1, @@ -235,15 +278,10 @@ test("inboxRunner - rotates deliveries across multiple recipients", async () => JSON.stringify(measurement.errors) }`, ); - // Both recipients' personal inboxes received deliveries. - const hits = target.inboxHits(); - assert.ok( - hits.has("/users/alice/inbox"), - `expected alice's inbox to be hit; hits: ${JSON.stringify([...hits])}`, - ); - assert.ok( - hits.has("/users/bob/inbox"), - `expected bob's inbox to be hit; hits: ${JSON.stringify([...hits])}`, + // Each recipient's personal inbox received exactly half of the deliveries. + assert.deepStrictEqual( + Object.fromEntries(target.inboxHits()), + { "/users/alice/inbox": 2, "/users/bob/inbox": 2 }, ); } finally { try { @@ -252,7 +290,19 @@ test("inboxRunner - rotates deliveries across multiple recipients", async () => await target.close(); } } -}); +} + +test( + "inboxRunner - allocates jit deliveries across multiple recipients", + { timeout: 60_000 }, + () => assertAllocatesAcrossRecipients("jit"), +); + +test( + "inboxRunner - allocates presigned deliveries across multiple recipients", + { timeout: 60_000 }, + () => assertAllocatesAcrossRecipients("presign"), +); test("inboxRunner.validate - rejects activity options it cannot honor", () => { function resolve(activity: Record) { diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 8b12b8ba0..c94547a0b 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -17,6 +17,7 @@ import { getContextLoader } from "./docloader.ts"; import { runCli } from "./runner.ts"; import { authorizedFetchOption, + classifyRecursiveLookupFailure, collectAsyncItems, collectRecursiveObjects, getLookupFailureHint, @@ -24,6 +25,7 @@ import { getRecursiveTargetId, isDocumentLoaderTimeoutError, lookupCommand, + printRecursiveLookupFailure, RecursiveLookupError, runLookup, shouldPrintLookupFailureHint, @@ -777,6 +779,237 @@ test("getRecursiveTargetId - returns null for unknown recurse property", () => { ); }); +const recursiveFailureOptions = { + allowPrivateAddress: false, + authorizedFetch: false, +}; + +function privateContextError(url: string): Error { + return Object.assign( + new Error(`Could not retrieve a valid JSON-LD object. URL: "${url}"`), + { name: "jsonld.InvalidUrl", details: { url } }, + ); +} + +test("classifyRecursiveLookupFailure - preserves target and timeout presentation", () => { + const target = "https://example.com/parent"; + const timeout = new TimeoutError("timed out"); + const cases = [ + // Raw fallback is also supported when no recursive target was retained. + { error: timeout, target: undefined, presentation: "generic" }, + { + error: new RecursiveLookupError(target, undefined, { cause: timeout }), + target, + presentation: "generic", + }, + { + error: new RecursiveLookupError(target, { + error: timeout, + source: "object", + }), + target, + presentation: "target", + }, + { + error: new RecursiveLookupError(target, { + error: timeout, + source: "context", + }, { cause: privateContextError("http://localhost/context") }), + target, + presentation: "target", + }, + ]; + for (const { error, target, presentation } of cases) { + const failure = classifyRecursiveLookupFailure( + error, + recursiveFailureOptions, + ); + assert.equal(failure.kind, "timeout"); + assert.equal(failure.target, target); + assert.equal(failure.presentation, presentation); + assert.equal(failure.suppressible, true); + assert.deepEqual(failure.hint, { type: "none" }); + assert.equal( + failure.error, + presentation === "generic" && error.cause != null ? error.cause : error, + ); + } +}); + +test("classifyRecursiveLookupFailure - distinguishes private targets and contexts", () => { + const target = "http://localhost/parent"; + const contextUrl = "http://127.0.0.1/context"; + const contextError = privateContextError(contextUrl); + const recorded = { error: contextError, source: "context" as const }; + const wrapped = new RecursiveLookupError(target, recorded); + const blocked = classifyRecursiveLookupFailure( + wrapped, + recursiveFailureOptions, + ); + assert.equal(blocked.kind, "context"); + assert.deepEqual(blocked.hint, { type: "private-address" }); + const allowed = classifyRecursiveLookupFailure(wrapped, { + ...recursiveFailureOptions, + allowPrivateAddress: true, + }); + assert.equal(allowed.kind, "context"); + assert.deepEqual(allowed.hint, { + type: "private-context", + url: new URL(contextUrl), + }); + // The actual jsonld path retains the loader error but throws its wrapper. + const raw = new RecursiveLookupError(target, { + error: new UrlError("Invalid or private address", { reason: "disallowed" }), + source: "context", + }, { cause: contextError }); + const failure = classifyRecursiveLookupFailure(raw, recursiveFailureOptions); + assert.equal(failure.target, target); + assert.equal(failure.source, "context"); + assert.equal(failure.kind, "context"); + assert.equal(failure.presentation, "generic"); + assert.equal(failure.error, contextError); + assert.equal(failure.suppressible, true); + assert.deepEqual(failure.hint, allowed.hint); + + const http = new FetchError(target, `HTTP 500: ${target}`); + const allowedHttp = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { error: http, source: "object" }), + { ...recursiveFailureOptions, allowPrivateAddress: true }, + ); + assert.equal(allowedHttp.kind, "fetch-or-parse"); + assert.deepEqual(allowedHttp.hint, { + type: "diagnostic", + message: `HTTP 500 from ${target}.`, + }); + const privateAddress = classifyRecursiveLookupFailure( + new RecursiveLookupError("https://example.com/parent", { + error: new UrlError("Invalid or private address"), + source: "object", + }), + recursiveFailureOptions, + ); + assert.equal(privateAddress.kind, "private-address"); + assert.deepEqual(privateAddress.hint, { + type: "diagnostic", + message: "Invalid or private address", + }); +}); + +test("classifyRecursiveLookupFailure - preserves recorded and raw DNS hints", () => { + const target = "https://example.com/parent"; + const error = new UrlError("Invalid or private address", { reason: "dns" }); + for (const authorizedFetch of [false, true]) { + const options = { ...recursiveFailureOptions, authorizedFetch }; + const raw = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { error, source: "object" }, { + cause: error, + }), + options, + ); + assert.equal(raw.kind, "fetch-or-parse"); + assert.equal(raw.presentation, "generic"); + assert.deepEqual(raw.hint, { type: "dns" }); + const recorded = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { error, source: "object" }), + options, + ); + assert.equal(recorded.kind, "fetch-or-parse"); + assert.deepEqual(recorded.hint, { + type: "diagnostic", + message: + "Could not resolve the host in the URL. Check the URL and your network connection.", + }); + } +}); + +test("classifyRecursiveLookupFailure - retains context, HTTP and portable diagnostics", () => { + const target = "https://example.com/parent"; + for (const status of [401, 403, 404]) { + const error = new FetchError(target, `HTTP ${status}: ${target}`); + for (const source of ["object", "context"] as const) { + for (const authorizedFetch of [false, true]) { + const failure = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { error, source }), + { ...recursiveFailureOptions, authorizedFetch }, + ); + assert.equal( + failure.kind, + source === "context" ? "context" : "fetch-or-parse", + ); + assert.equal(failure.source, source); + assert.equal(failure.suppressible, true); + assert.deepEqual(failure.hint, { + type: "diagnostic", + message: `HTTP ${status} from ${target}.` + + (source === "object" && !authorizedFetch + ? " It may be a private object. Try with -a/--authorized-fetch." + : ""), + }); + } + } + } + const parse = new SyntaxError("Unexpected token"); + const context = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { error: parse, source: "context" }, { + cause: new Error("JSON-LD processing failed"), + }), + recursiveFailureOptions, + ); + assert.equal(context.kind, "context"); + assert.equal(context.presentation, "generic"); + assert.deepEqual(context.hint, { type: "authorized-fetch" }); + const portable = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, { + error: undefined, + source: "portable", + id: "ap://did:key:example/parent", + problem: "no-gateway", + }), + recursiveFailureOptions, + ); + assert.equal(portable.kind, "fetch-or-parse"); + assert.equal(portable.source, "portable"); + assert.equal(portable.suppressible, true); + assert.deepEqual(portable.hint, { + type: "diagnostic", + message: + "The portable ID ap://did:key:example/parent has no @gateway location hints. Use the --gateway option to look it up.", + }); +}); + +test("classifyRecursiveLookupFailure - preserves unknown and authenticated hints", () => { + const target = "https://example.com/parent"; + for (const authorizedFetch of [false, true]) { + const options = { ...recursiveFailureOptions, authorizedFetch }; + // No evidence means a null result, not proof of an HTTP 404. + const missing = classifyRecursiveLookupFailure( + new RecursiveLookupError(target), + options, + ); + assert.equal(missing.kind, "not-found"); + assert.equal(missing.suppressible, true); + assert.deepEqual(missing.hint, { + type: authorizedFetch ? "none" : "authorized-fetch", + }); + const raw = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, undefined, { cause: "parse failed" }), + options, + ); + assert.equal(raw.kind, "fetch-or-parse"); + assert.equal(raw.error, "parse failed"); + assert.equal(raw.target, target); + assert.deepEqual(raw.hint, { + type: authorizedFetch ? "suppress-errors" : "authorized-fetch", + }); + } + const undefinedCause = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, undefined, { cause: undefined }), + recursiveFailureOptions, + ); + assert.equal(undefinedCause.presentation, "target"); + assert.equal(undefinedCause.kind, "not-found"); +}); + test("getLookupFailureHint - suggests private-address for UrlError", () => { assert.equal( getLookupFailureHint( @@ -1168,6 +1401,97 @@ async function captureStderr( } } +test("printRecursiveLookupFailure - preserves timeout targets and authenticated hints", async () => { + const target = "https://lookup.test/parent"; + const timeout = new TimeoutError("timed out"); + for (const recorded of [false, true]) { + const failure = classifyRecursiveLookupFailure( + new RecursiveLookupError( + target, + recorded ? { error: timeout, source: "object" } : undefined, + { cause: timeout }, + ), + recursiveFailureOptions, + ); + const headlines: string[] = []; + const { stderr } = await captureStderr(() => { + printRecursiveLookupFailure(failure, { + fail: (headline) => { + headlines.push(headline); + }, + }, 0.2); + return Promise.resolve(); + }); + assert.equal(headlines.length, 1); + assert.match(headlines[0], /Request timed out after 0\.2 seconds/); + if (recorded) assert.match(headlines[0], /for: .*lookup\.test\/parent/); + else assert.equal(headlines[0], "Request timed out after 0.2 seconds."); + assert.match(stderr, /--timeout/); + } + const failure = classifyRecursiveLookupFailure( + new RecursiveLookupError(target, undefined, { + cause: new Error("parse failed"), + }), + { ...recursiveFailureOptions, authorizedFetch: true }, + ); + const headlines: string[] = []; + const { stderr } = await captureStderr(() => { + printRecursiveLookupFailure(failure, { + fail: (headline) => { + headlines.push(headline); + }, + }); + return Promise.resolve(); + }); + assert.deepEqual(headlines, ["Failed to recursively fetch object."]); + assert.match(stderr, /--suppress-errors/); + assert.doesNotMatch(stderr, /--authorized-fetch/); +}); + +test("runLookup - reports recorded recursive timeouts with the target", async () => { + const testDir = getTestOutputDir("runlookup_recursive_timeout"); + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const rootUrl = "https://lookup.test/root"; + const target = "https://lookup.test/parent"; + const root = new Note({ + id: new URL(rootUrl), + replyTarget: new URL(target), + }); + const { result: exitCode, stderr } = await captureStderr(() => + runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl], + recurse: "replyTarget", + recurseDepth: 20, + timeout: 0.2, + output: testFile, + }), + { + lookupObject: (url) => { + if (String(url) === rootUrl) return Promise.resolve(root); + throw new TimeoutError("timed out"); + }, + }, + ) + ); + assert.equal(exitCode, 1); + assert.match( + stderr, + /Request timed out after 0\.2 seconds for: .*lookup\.test\/parent/, + ); + assert.match(stderr, /--timeout/); + assert.doesNotMatch(stderr, /--authorized-fetch/); + assert.deepEqual( + extractIdsFromRawOutput(await readFile(testFile, "utf8")), + [rootUrl], + ); + } finally { + await rm(testDir, { recursive: true }); + } +}); + test("runLookup - prints DNS guidance for a thrown recursive lookup failure", async () => { const testDir = "./test_output_runlookup_recursive_dns"; await mkdir(testDir, { recursive: true }); @@ -1431,6 +1755,43 @@ test("runLookup - keeps recursive private contexts blocked", async () => { } }); +test("runLookup - suppresses private context failures in either presentation order", async () => { + for (const reverse of [false, true]) { + const testDir = getTestOutputDir( + `runlookup_private_context_suppressed_${reverse}`, + ); + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + await withRecursiveLookupServer( + { replyContextPath: "/contexts/reply" }, + async ({ rootUrl, requestedPaths }) => { + const { result: exitCode, stderr } = await captureStderr(() => + runLookupAndCaptureExitCode(createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: true, + suppressErrors: true, + reverse, + output: testFile, + })) + ); + assert.equal(exitCode, 0); + assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); + assert.doesNotMatch(stderr, /always blocked|--authorized-fetch/); + assert.deepEqual( + extractIdsFromRawOutput(await readFile(testFile, "utf8")), + [rootUrl.href], + ); + }, + ); + } finally { + await rm(testDir, { recursive: true }); + } + } +}); + test("runLookup - reverses output order in default multi-input mode", async () => { const testDir = getTestOutputDir("runlookup_default_reverse"); const testFile = `${testDir}/out.jsonl`; diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index ee2b72959..0d52cc994 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -88,8 +88,8 @@ export class RecursiveLookupError extends Error { target: string; /** Why the target could not be fetched, if known. */ failure?: LookupFailure; - constructor(target: string, failure?: LookupFailure) { - super(`Failed to recursively fetch object: ${target}`); + constructor(target: string, failure?: LookupFailure, options?: ErrorOptions) { + super(`Failed to recursively fetch object: ${target}`, options); this.name = "RecursiveLookupError"; this.target = target; this.failure = failure; @@ -478,6 +478,192 @@ function printLookupFailureHint( } } +/** A follow-up lookup failure, classified without changing loader policy. */ +export interface RecursiveLookupFailure { + /** The recursive object target, when the lookup retained it. */ + readonly target?: string; + /** + * `not-found` means no object and no recorded evidence, not an HTTP 404. + * Context failures retain their source even when classified as timeouts + * or private-address rejections. + */ + readonly kind: + | "timeout" + | "private-address" + | "not-found" + | "context" + | "fetch-or-parse"; + readonly source?: LookupFailure["source"]; + /** + * Whether suppression can return the already collected prefix. This model + * covers follow-up fetches only, never initial lookups or output writes. + */ + readonly suppressible: boolean; + /** Preserve generic headlines for errors that previously escaped directly. */ + readonly presentation: "target" | "generic"; + /** The error to log, preserving the original generic error when available. */ + readonly error: unknown; + readonly hint: + | { readonly type: "private-address" } + | { readonly type: "private-context"; readonly url: URL } + | { readonly type: "dns" } + | { readonly type: "authorized-fetch" } + | { readonly type: "suppress-errors" } + | { readonly type: "diagnostic"; readonly message: string } + | { readonly type: "none" }; +} + +function isSuppressibleRecursiveFailure( + kind: RecursiveLookupFailure["kind"], +): boolean { + switch (kind) { + case "timeout": + case "private-address": + case "not-found": + case "context": + case "fetch-or-parse": + return true; + } +} + +/** + * Classifies recursive follow-up failures and selects their existing hints. + * @param error The error from collecting a recursive chain. + * @param options The policies that affect hints, rather than loader behavior. + * @returns A failure that can be rendered without inspecting its error again. + */ +export function classifyRecursiveLookupFailure( + error: unknown, + options: { allowPrivateAddress: boolean; authorizedFetch: boolean }, +): RecursiveLookupFailure { + const wrapped = error instanceof RecursiveLookupError ? error : undefined; + const failure = wrapped?.failure; + const recordedTimeout = failure?.error instanceof TimeoutError; + const presentation = wrapped != null && + (wrapped.cause == null || recordedTimeout) + ? "target" + : "generic"; + const underlyingError = presentation === "target" + ? failure?.error + : wrapped?.cause ?? error; + const timeout = underlyingError instanceof TimeoutError || recordedTimeout; + const privateContextUrl = presentation === "generic" || + failure?.source === "context" + ? getPrivateContextUrl(underlyingError) + : null; + const blockedTarget = presentation === "target" && + !options.allowPrivateAddress && wrapped != null && + isPrivateAddressTarget(wrapped.target); + + let hint: RecursiveLookupFailure["hint"] = { type: "none" }; + if (!timeout) { + if (presentation === "target") { + if (blockedTarget) hint = { type: "private-address" }; + else if (privateContextUrl != null) { + hint = { type: "private-context", url: privateContextUrl }; + } else if (failure != null) { + hint = { + type: "diagnostic", + message: describeLookupFailure(failure, options.authorizedFetch) + .message, + }; + } else if (!options.authorizedFetch) { + hint = { type: "authorized-fetch" }; + } + } else if (privateContextUrl != null) { + hint = { type: "private-context", url: privateContextUrl }; + } else { + const legacyHint = getLookupFailureHint(underlyingError, { + recursive: true, + }); + hint = legacyHint === "authorized-fetch" + ? { type: options.authorizedFetch ? "suppress-errors" : legacyHint } + : { type: legacyHint === "dns" ? "dns" : "private-address" }; + } + } + + const kind: RecursiveLookupFailure["kind"] = timeout + ? "timeout" + : privateContextUrl != null + ? "context" + // FetchError messages include their URL; "localhost" in that URL is + // not evidence that the loader blocked the address. + : blockedTarget || + !(underlyingError instanceof FetchError) && + isPrivateAddressError(underlyingError) + ? "private-address" + : failure?.source === "context" + ? "context" + : presentation === "target" && failure == null + ? "not-found" + : "fetch-or-parse"; + return { + target: wrapped?.target, + kind, + source: failure?.source, + suppressible: isSuppressibleRecursiveFailure(kind), + presentation, + error: presentation === "generic" ? underlyingError : error, + hint, + }; +} + +/** + * Renders a classified recursive failure without reinterpreting its error. + * @param failure The classified follow-up failure. + * @param spinner The spinner that displays the failure headline. + * @param timeoutSeconds The requested timeout, in seconds. + * @internal + */ +export function printRecursiveLookupFailure( + failure: RecursiveLookupFailure, + spinner: { fail: (text: string) => void }, + timeoutSeconds?: number, +): void { + const target = failure.presentation === "target" ? failure.target : undefined; + if (failure.kind === "timeout") { + handleTimeoutError(spinner, timeoutSeconds, target); + return; + } + spinner.fail( + target == null + ? "Failed to recursively fetch object." + : `Failed to recursively fetch object: ${colors.red(target)}.`, + ); + switch (failure.hint.type) { + case "private-address": + printRecursivePrivateAddressHint(); + return; + case "private-context": + printRecursivePrivateContextHint(failure.hint.url); + return; + case "dns": + printError( + message`DNS lookup failed. Check the hostname and network connectivity.`, + ); + return; + case "authorized-fetch": + printError( + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, + ); + return; + case "suppress-errors": + printError( + message`Use the ${ + optionNames(["-S", "--suppress-errors"]) + } option to suppress partial errors.`, + ); + return; + case "diagnostic": + printError(message`${text(failure.hint.message)}`); + return; + case "none": + return; + } +} + /** * Gets the next recursion target URL from an ActivityPub object. */ @@ -914,13 +1100,13 @@ export async function runLookup( : {}), }); if (result.object != null) return result.object; - if ( - result.thrownError != null && - !(result.failure?.error instanceof TimeoutError) - ) { - throw result.thrownError; - } - throw new RecursiveLookupError(target, result.failure); + throw new RecursiveLookupError( + target, + result.failure, + result.thrownError == null + ? undefined + : { cause: result.thrownError }, + ); }, { suppressErrors: command.suppressErrors, visited }, ); @@ -947,65 +1133,15 @@ export async function runLookup( return; } } + const failure = classifyRecursiveLookupFailure(error, { + allowPrivateAddress: command.allowPrivateAddress, + authorizedFetch: authLoader != null, + }); logger.error( "Failed to recursively fetch an object in chain: {error}", - { - error, - }, + { error: failure.error }, ); - if (error instanceof TimeoutError) { - handleTimeoutError(spinner, command.timeout); - } else if ( - error instanceof RecursiveLookupError && - error.failure?.error instanceof TimeoutError - ) { - handleTimeoutError(spinner, command.timeout, error.target); - } else if (error instanceof RecursiveLookupError) { - spinner.fail( - `Failed to recursively fetch object: ${colors.red(error.target)}.`, - ); - const privateContextUrl = error.failure?.source === "context" - ? getPrivateContextUrl(error.failure.error) - : null; - if ( - !command.allowPrivateAddress && - isPrivateAddressTarget(error.target) - ) { - printRecursivePrivateAddressHint(); - } else if (privateContextUrl != null) { - printRecursivePrivateContextHint(privateContextUrl); - } else if (error.failure != null) { - const diagnostic = describeLookupFailure( - error.failure, - authLoader != null, - ); - printError(message`${text(diagnostic.message)}`); - } else if (authLoader == null) { - printError( - message`It may be a private object. Try with ${ - optionNames(["-a", "--authorized-fetch"]) - }.`, - ); - } - } else { - spinner.fail("Failed to recursively fetch object."); - const privateContextUrl = getPrivateContextUrl(error); - if (privateContextUrl != null) { - printRecursivePrivateContextHint(privateContextUrl); - await finalizeAndExit(1); - return; - } - const hint = getLookupFailureHint(error, { recursive: true }); - if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) { - printError( - message`Use the ${ - optionNames(["-S", "--suppress-errors"]) - } option to suppress partial errors.`, - ); - } else { - printLookupFailureHint(authLoader, error, { recursive: true }); - } - } + printRecursiveLookupFailure(failure, spinner, command.timeout); await finalizeAndExit(1); return; } diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index 5e0d2f111..0ebd2b8f4 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -1,7 +1,7 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; import { Chalk } from "chalk"; import fetchMock from "fetch-mock"; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; import { getAsciiArt, getFaviconUrl, @@ -10,6 +10,10 @@ import { rgbTo256Color, } from "./nodeinfo.ts"; +afterEach(() => { + fetchMock.hardReset(); +}); + const HTML_WITH_SMALL_ICON = ` @@ -32,8 +36,6 @@ test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => { const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/apple-touch-icon.png"); - - fetchMock.hardReset(); }); const HTML_WITH_ICON = ` @@ -58,8 +60,6 @@ test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => { const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/favicon.ico"); - - fetchMock.hardReset(); }); const HTML_WITH_SVG_ONLY = ` @@ -83,8 +83,6 @@ test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => { const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/favicon.ico"); - - fetchMock.hardReset(); }); const HTML_WITH_UPPERCASE_SVG_ONLY = ` @@ -92,7 +90,7 @@ const HTML_WITH_UPPERCASE_SVG_ONLY = ` Test Site - + Test @@ -108,8 +106,6 @@ test("getFaviconUrl - uppercase svg icons only falls back to /favicon.ico", asyn const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/favicon.ico"); - - fetchMock.hardReset(); }); const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = ` @@ -117,7 +113,7 @@ const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = ` Test Site - + Test @@ -133,8 +129,123 @@ test("getFaviconUrl - uppercase svg icons with query and hash fall back to /favi const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/favicon.ico"); +}); - fetchMock.hardReset(); +const HTML_WITH_SVG_TYPE_ONLY = ` + + + + Test Site + + +Test + +`; + +test("getFaviconUrl - svg icons with type attribute only falls back to /favicon.ico", async () => { + fetchMock.spyGlobal(); + + fetchMock.get("https://example.com/", { + body: HTML_WITH_SVG_TYPE_ONLY, + headers: { "Content-Type": "text/html" }, + }); + + const result = await getFaviconUrl("https://example.com/"); + assert.equal(result.href, "https://example.com/favicon.ico"); +}); + +const HTML_WITH_SIZES_ANY_ICON = ` + + + + Test Site + + +Test + +`; + +test("getFaviconUrl - icons with sizes='any' is selected", async () => { + fetchMock.spyGlobal(); + + fetchMock.get("https://example.com/", { + body: HTML_WITH_SIZES_ANY_ICON, + headers: { "Content-Type": "text/html" }, + }); + + const result = await getFaviconUrl("https://example.com/"); + assert.equal(result.href, "https://example.com/favicon.png"); +}); + +const HTML_WITH_MULTI_SIZE_ICON = ` + + + + Test Site + + + +Test + +`; + +test("getFaviconUrl - icons with multiple sizes", async () => { + fetchMock.spyGlobal(); + + fetchMock.get("https://example.com/", { + body: HTML_WITH_MULTI_SIZE_ICON, + headers: { "Content-Type": "text/html" }, + }); + + const result = await getFaviconUrl("https://example.com/"); + assert.equal(result.href, "https://example.com/multi-size-icon.ico"); +}); + +const HTML_WITH_PREFERRED_BITMAP_ICON = ` + + + + Test Site + + + +Test + +`; + +test("getFaviconUrl - prefer bitmap icons", async () => { + fetchMock.spyGlobal(); + + fetchMock.get("https://example.com/", { + body: HTML_WITH_PREFERRED_BITMAP_ICON, + headers: { "Content-Type": "text/html" }, + }); + + const result = await getFaviconUrl("https://example.com/"); + assert.equal(result.href, "https://example.com/favicon.png"); +}); + +const HTML_WITH_MULTIPLE_REL_TOKENS = ` + + + + Test Site + + +Test + +`; + +test("getFaviconUrl - icon with multiple rel tokens", async () => { + fetchMock.spyGlobal(); + + fetchMock.get("https://example.com/", { + body: HTML_WITH_MULTIPLE_REL_TOKENS, + headers: { "Content-Type": "text/html" }, + }); + + const result = await getFaviconUrl("https://example.com/"); + assert.equal(result.href, "https://example.com/favicon.png"); }); const HTML_WITHOUT_ICON = ` @@ -157,8 +268,6 @@ test("getFaviconUrl - falls back to /favicon.ico", async () => { const result = await getFaviconUrl("https://example.com/"); assert.equal(result.href, "https://example.com/favicon.ico"); - - fetchMock.hardReset(); }); test("rgbTo256Color - check RGB cube", () => { diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 1ed07e136..fde225eca 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -371,13 +371,17 @@ export async function getFaviconUrl( } const rel = attrs.rel?.toLowerCase()?.trim()?.split(/\s+/) ?? []; if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue; - if ("sizes" in attrs && attrs.sizes.match(/\d+x\d+/)) { - const [w, h] = attrs.sizes.split("x").map((v) => Number.parseInt(v)); - if (w < 38 || h < 19) continue; - } + const tokens = attrs.sizes?.match(/\d+x\d+/g); + if ( + tokens != null && !tokens.some((token) => { + const [w, h] = token.split("x").map((v) => Number.parseInt(v)); + return w >= 38 && h >= 19; + }) + ) continue; if ("href" in attrs) { const parsedUrl = new URL(attrs.href, response.url); if (parsedUrl.pathname.toLowerCase().endsWith(".svg")) continue; + if (attrs.type?.toLowerCase()?.trim() === "image/svg+xml") continue; return parsedUrl; } } diff --git a/packages/cli/src/portable.test.ts b/packages/cli/src/portable.test.ts index 7ccdfd1fe..d1788bccc 100644 --- a/packages/cli/src/portable.test.ts +++ b/packages/cli/src/portable.test.ts @@ -36,6 +36,7 @@ import { import { matchesActor } from "./utils.ts"; import { linksToActor, lookupPortableWebFinger } from "./webfinger/action.ts"; import { webFingerCommand } from "./webfinger/command.ts"; +import { WebFingerLookupError } from "./webfinger/error.ts"; const keyPair = await crypto.subtle.generateKey("Ed25519", true, [ "sign", @@ -901,6 +902,35 @@ test("lookupPortableWebFinger() uses the first gateway of the actor", async () = } }); +test("lookupPortableWebFinger() preserves WebFinger diagnostics", async () => { + const gateway = await startGateway(); + fetchMock.spyGlobal(); + const resource = `ap://${did}/actor?@gateway=${ + encodeURIComponent(gateway.url.origin) + }`; + fetchMock.get(`begin:https://${gateway.url.host}/.well-known/webfinger`, { + status: 500, + }); + try { + await serveFixtures(gateway); + await assert.rejects( + lookupPortableWebFinger({ + resource, + allowPrivateAddresses: true, + }), + (error: unknown) => { + assert.ok(error instanceof WebFingerLookupError); + assert.equal(error.result, "error"); + return true; + }, + ); + } finally { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + await gateway.close(); + } +}); + test("linksToActor() checks the first ActivityStreams self link", () => { const actorId = portableId("/actor"); const compatible = diff --git a/packages/cli/src/webfinger/action.ts b/packages/cli/src/webfinger/action.ts index 2d15143fd..ce8c108c4 100644 --- a/packages/cli/src/webfinger/action.ts +++ b/packages/cli/src/webfinger/action.ts @@ -22,6 +22,7 @@ import { NotFoundError, PortableActorError, UnlinkedPortableActorError, + WebFingerLookupError, } from "./error.ts"; import { convertUrlIfHandle } from "./lib.ts"; @@ -96,11 +97,15 @@ export async function lookupSingleWebFinger( options: LookupSingleWebFingerOptions, ): Promise { const url = convertUrlIfHandle(options.resource); - const webFinger = await lookupWebFinger( - url, - getLookupWebFingerOptions(options), - ) ?? new NotFoundError(options.resource).throw(); - return webFinger; + const outcome = await lookupWebFinger(url, { + ...getLookupWebFingerOptions(options), + withDiagnostics: true, + }); + return outcome.resource ?? ( + outcome.result === "not_found" + ? new NotFoundError(options.resource).throw() + : new WebFingerLookupError(options.resource, outcome.result).throw() + ); } /** @@ -119,6 +124,9 @@ export async function lookupSingleWebFinger( * @throws {PortableActorError} If the actor cannot be looked up, or does * not have a WebFinger address. * @throws {NotFoundError} If the WebFinger address is not found. + * @throws {WebFingerLookupError} If the WebFinger lookup failed for another + * reason, such as a network failure or a + * malformed response. */ export async function lookupPortableWebFinger( options: LookupSingleWebFingerOptions, @@ -168,10 +176,15 @@ export async function lookupPortableWebFinger( ); } const address = `acct:${username}@${gateway.host}`; - const descriptor = await lookupWebFinger( - address, - getLookupWebFingerOptions(options), - ) ?? new NotFoundError(address).throw(); + const outcome = await lookupWebFinger(address, { + ...getLookupWebFingerOptions(options), + withDiagnostics: true, + }); + const descriptor = outcome.resource ?? ( + outcome.result === "not_found" + ? new NotFoundError(address).throw() + : new WebFingerLookupError(address, outcome.result).throw() + ); return { descriptor, address, actorId: actor.id }; } diff --git a/packages/cli/src/webfinger/error.ts b/packages/cli/src/webfinger/error.ts index 003adee40..518cc6051 100644 --- a/packages/cli/src/webfinger/error.ts +++ b/packages/cli/src/webfinger/error.ts @@ -1,4 +1,5 @@ import { type Message, message } from "@optique/core"; +import type { WebFingerLookupResult } from "@fedify/webfinger"; /** * Generates a user-friendly error message based on the type of error @@ -16,6 +17,12 @@ export const getErrorMessage = (resource: string, error: unknown): Message => ? message`${error.address} does not link back to the portable actor ${error.actorId}, so it is not verified to be the actor's WebFinger address:` : error instanceof NotFoundError ? message`Resource not found: ${error.resource}` + : error instanceof WebFingerLookupError + ? error.result === "network_error" + ? message`Failed to look up WebFinger for ${error.resource}: a network error occurred.` + : error.result === "invalid" + ? message`Failed to look up WebFinger for ${error.resource}: the server returned a response Fedify could not understand.` + : message`Failed to look up WebFinger for ${error.resource}: the server returned an unexpected error.` : error instanceof Error ? message`Failed to look up WebFinger for ${resource}: ${error.message}` : message`Failed to look up WebFinger for ${resource}: ${String(error)}`; @@ -50,6 +57,26 @@ export class NotFoundError extends Error { } } +/** + * Custom error class for a WebFinger lookup that failed for a reason other + * than the resource simply not existing, such as a network failure, a + * server error, or a response Fedify could not parse. + * @param resource The resource that could not be looked up. + * @param result Why the lookup failed. + * @extends {Error} + */ +export class WebFingerLookupError extends Error { + constructor(public resource: string, public result: WebFingerLookupResult) { + super( + `Failed to look up WebFinger for ${resource}: result was "${result}".`, + ); + this.name = "WebFingerLookupError"; + } + throw(): never { + throw this; + } +} + /** * Custom error class for [FEP-ef61] portable actors whose WebFinger address * cannot be determined. diff --git a/packages/cli/src/webfinger/mod.test.ts b/packages/cli/src/webfinger/mod.test.ts index 12e09dd39..701be7586 100644 --- a/packages/cli/src/webfinger/mod.test.ts +++ b/packages/cli/src/webfinger/mod.test.ts @@ -1,8 +1,17 @@ +import { formatMessage } from "@optique/core/message"; import { parse } from "@optique/core/parser"; +import { run } from "@optique/run"; import assert from "node:assert/strict"; +import process from "node:process"; import test from "node:test"; -import { lookupSingleWebFinger } from "./action.ts"; +import { type Config, configContext } from "../config.ts"; +import runWebFinger, { lookupSingleWebFinger } from "./action.ts"; import { webFingerCommand } from "./command.ts"; +import { + getErrorMessage, + NotFoundError, + WebFingerLookupError, +} from "./error.ts"; const COMMAND = "webfinger"; const USER_AGENT = "MyUserAgent/1.0"; @@ -15,6 +24,95 @@ const ALIASES = [ "https://hollo.social/@fedify", ]; +for ( + const { name, args, config, allowed } of [ + { name: "short flag", args: ["-p"], config: {}, allowed: true }, + { + name: "long flag", + args: ["--allow-private-address"], + config: {}, + allowed: true, + }, + { + name: "configuration opt-in", + args: [], + config: { webfinger: { allowPrivateAddress: true } }, + allowed: true, + }, + { name: "default rejection", args: [], config: {}, allowed: false }, + { + name: "configuration opt-out", + args: [], + config: { webfinger: { allowPrivateAddress: false } }, + allowed: false, + }, + { + name: "flag overrides configuration opt-out", + args: ["-p"], + config: { webfinger: { allowPrivateAddress: false } }, + allowed: true, + }, + ] satisfies { + name: string; + args: string[]; + config: Config; + allowed: boolean; + }[] +) { + test(`runWebFinger private address - ${name}`, async () => { + const resource = "http://127.0.0.1/users/alice"; + const descriptor = { subject: resource, aliases: [resource] }; + const requests: { url: string; userAgent: string | null }[] = []; + const originalFetch = globalThis.fetch; + const originalWrite = process.stdout.write; + let output = ""; + globalThis.fetch = (input, init) => { + requests.push({ + url: String(input), + userAgent: new Headers(init?.headers).get("User-Agent"), + }); + return Promise.resolve( + new Response(JSON.stringify(descriptor), { + headers: { "Content-Type": "application/jrd+json" }, + }), + ); + }; + process.stdout.write = function (...args) { + output += String(args[0]); + return Reflect.apply(originalWrite, this, args); + }; + try { + const options = await run(webFingerCommand, { + contexts: [configContext], + contextOptions: { load: () => ({ config, meta: undefined }) }, + args: [COMMAND, ...args, "-u", USER_AGENT, resource], + }); + await runWebFinger(options); + } finally { + globalThis.fetch = originalFetch; + process.stdout.write = originalWrite; + } + // Assert after execution: lookup and spinner error handling swallow errors + // thrown inside fetch, so assertions in the mock would not fail the test. + assert.deepEqual( + requests, + allowed + ? [{ + url: "http://127.0.0.1/.well-known/webfinger?resource=" + + encodeURIComponent(resource), + userAgent: USER_AGENT, + }] + : [], + ); + if (allowed) { + assert.ok(output.includes(resource), output); + assert.ok(output.includes("subject"), output); + } else { + assert.strictEqual(output, ""); + } + }); +} + test("Test webFingerCommand - resources only", async () => { const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; const result = await parse(webFingerCommand, argsWithResourcesOnly); @@ -133,7 +231,9 @@ test("Test lookupSingleWebFinger", async (): Promise => { try { const results = await Array.fromAsync( RESOURCES, - (resource) => lookupSingleWebFinger({ resource }), + // HTTP responses are mocked, so skip the SSRF guard's external DNS lookup. + (resource) => + lookupSingleWebFinger({ resource, allowPrivateAddresses: true }), ); const aliases = results.map((w) => w?.aliases?.[0]); @@ -142,3 +242,83 @@ test("Test lookupSingleWebFinger", async (): Promise => { globalThis.fetch = originalFetch; } }); + +// ---------------------------------------------------------------------- +// FIX FOR ISSUE #897 – DIFFERENTIATE WHY A WEBFINGER LOOKUP FAILED +// ---------------------------------------------------------------------- + +for ( + const { name, response, expectedResult, expectedText } of [ + { + name: "404 is still reported as not found", + response: () => new Response(null, { status: 404 }), + expectedResult: null, + expectedText: 'Resource not found: "acct:alice@example.com"', + }, + { + name: "a server error is distinguished from not found", + response: () => new Response(null, { status: 500 }), + expectedResult: "error", + expectedText: "the server returned an unexpected error", + }, + { + name: "a malformed response is distinguished from not found", + response: () => new Response("not json", { status: 200 }), + expectedResult: "invalid", + expectedText: + "the server returned a response Fedify could not understand", + }, + ] satisfies { + name: string; + response: () => Response; + expectedResult: "error" | "invalid" | null; + expectedText: string; + }[] +) { + test(`Test lookupSingleWebFinger - ${name}`, async () => { + const resource = "acct:alice@example.com"; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(response()); + try { + await assert.rejects( + () => lookupSingleWebFinger({ resource, allowPrivateAddresses: true }), + (error: unknown) => { + if (expectedResult == null) { + assert.ok(error instanceof NotFoundError); + } else { + assert.ok(error instanceof WebFingerLookupError); + assert.strictEqual(error.result, expectedResult); + } + const text = formatMessage(getErrorMessage(resource, error)); + assert.ok(text.includes(expectedText), text); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("Test lookupSingleWebFinger - a network failure is distinguished from not found", async () => { + const resource = "acct:alice@example.com"; + const originalFetch = globalThis.fetch; + // Mocked lookups skip DNS by passing allowPrivateAddresses, same as the + // upstream fix for issue #1273 (0bece1f8e): a real DNS lookup for a public + // hostname should not be part of a unit test. + globalThis.fetch = () => Promise.reject(new TypeError("network down")); + try { + await assert.rejects( + () => lookupSingleWebFinger({ resource, allowPrivateAddresses: true }), + (error: unknown) => { + assert.ok(error instanceof WebFingerLookupError); + assert.strictEqual(error.result, "network_error"); + const text = formatMessage(getErrorMessage(resource, error)); + assert.ok(text.includes("a network error occurred"), text); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/create/package.json b/packages/create/package.json index 9025b7665..2a94cefc6 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/create", - "version": "2.4.0", + "version": "2.5.0", "description": "Create a new Fedify application", "keywords": [ "fedify", diff --git a/packages/debugger/deno.json b/packages/debugger/deno.json index e85c35932..fa39b3527 100644 --- a/packages/debugger/deno.json +++ b/packages/debugger/deno.json @@ -1,6 +1,6 @@ { "name": "@fedify/debugger", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "exports": { ".": "./src/mod.tsx" diff --git a/packages/debugger/package.json b/packages/debugger/package.json index cea565924..7ea5c1e94 100644 --- a/packages/debugger/package.json +++ b/packages/debugger/package.json @@ -1,6 +1,6 @@ { "name": "@fedify/debugger", - "version": "2.4.0", + "version": "2.5.0", "description": "Embedded ActivityPub debug dashboard for Fedify", "type": "module", "main": "./dist/mod.cjs", diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts index 3f131c24c..4eaecb202 100644 --- a/packages/debugger/src/mod.test.ts +++ b/packages/debugger/src/mod.test.ts @@ -17,6 +17,7 @@ import type { TraceActivityRecord, TraceSummary, } from "@fedify/fedify/otel"; +import type { Sink } from "@logtape/logtape"; import { trace } from "@opentelemetry/api"; function createMockExporter( @@ -1251,3 +1252,609 @@ test("trace detail page shows empty log message", async () => { const html = await response.text(); ok(html.includes("No logs captured for this trace.")); }); + +// ---------- Trace list filtering tests ---------- + +function threeTypedTraces(): TraceSummary[] { + return [ + { + traceId: "a".repeat(32), + timestamp: "2026-01-01T00:00:00Z", + activityCount: 1, + activityTypes: ["Create"], + }, + { + traceId: "b".repeat(32), + timestamp: "2026-01-02T00:00:00Z", + activityCount: 1, + activityTypes: ["Follow"], + }, + { + traceId: "c".repeat(32), + timestamp: "2026-01-03T00:00:00Z", + activityCount: 1, + activityTypes: ["Like"], + }, + ]; +} + +test("traces list page filters by a single activity type", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Follow"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("1")); + ok(html.includes("bbbbbbbb")); + ok(!html.includes("aaaaaaaa")); + ok(!html.includes("cccccccc")); +}); + +test("traces list page filters by multiple activity types with OR semantics", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request( + "https://example.com/__debug__/?type=Create&type=Like", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("2")); + ok(html.includes("aaaaaaaa")); + ok(html.includes("cccccccc")); + ok(!html.includes("bbbbbbbb")); +}); + +test("traces list page shows filter-specific empty message when nothing matches", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Nope"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("No traces match the selected filters.")); + ok(!html.includes("No traces captured yet.")); +}); + +test("traces list page filter form retains the selected checkbox and shows a clear link", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Follow"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes('value="Follow" checked')); + ok(!html.includes('value="Create" checked')); + ok(!html.includes('value="Like" checked')); + ok(html.includes("Clear filters")); +}); + +test("traces list page filter form has no clear link when nothing is selected", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(!html.includes("Clear filters")); +}); + +test("JSON traces API filters by type", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request( + "https://example.com/__debug__/api/traces?type=Follow", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const body = (await response.json()) as TraceSummary[]; + strictEqual(body.length, 1); + strictEqual(body[0].traceId, "b".repeat(32)); +}); + +// ---------- Poll script tests ---------- + +/** Pulls the inline `'; + const request = new Request( + `https://example.com/__debug__/traces/${traceId}?q=${ + encodeURIComponent(malicious) + }`, + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(!html.includes("")); + ok(html.includes("<script>")); +}); diff --git a/packages/debugger/src/routes.tsx b/packages/debugger/src/routes.tsx index 15e196df5..569608798 100644 --- a/packages/debugger/src/routes.tsx +++ b/packages/debugger/src/routes.tsx @@ -5,7 +5,7 @@ * * @module */ -import type { FedifySpanExporter } from "@fedify/fedify/otel"; +import type { FedifySpanExporter, TraceSummary } from "@fedify/fedify/otel"; import { Hono } from "hono"; import { getCookie } from "hono/cookie"; import { @@ -16,11 +16,108 @@ import { signSession, verifySession, } from "./auth.ts"; -import type { LogStore } from "./log-store.ts"; +import type { LogStore, SerializedLogRecord } from "./log-store.ts"; import { LoginPage } from "./views/login.tsx"; import { TraceDetailPage } from "./views/trace-detail.tsx"; import { TracesListPage } from "./views/traces-list.tsx"; +/** Collects the distinct activity types across all traces, sorted. */ +function distinctActivityTypes(traces: readonly TraceSummary[]): string[] { + const types = new Set(); + for (const trace of traces) { + for (const type of trace.activityTypes) types.add(type); + } + return [...types].sort(); +} + +/** + * Keeps only the traces whose `activityTypes` include at least one of the + * given `types`. An empty `types` list means "no filter"—all traces pass + * through unchanged. + */ +function filterTracesByTypes( + traces: readonly TraceSummary[], + types: readonly string[], +): TraceSummary[] { + if (types.length === 0) return [...traces]; + return traces.filter((trace) => + trace.activityTypes.some((type) => types.includes(type)) + ); +} + +/** + * Computes a string that changes whenever the trace corpus changes in a + * way the traces list page's live-poll script needs to react to. Rather + * than comparing a handful of hand-picked summary fields—which has missed + * a real change each time a new one turned up—this serializes every trace + * that currently matches `selectedTypes` as `id:types:activityCount`, so + * a trace being replaced, an existing trace's activity count going up, or + * its activity types growing are all covered by the same comparison. The + * distinct activity types across *all* traces are tracked separately, + * since a type that does not match the active filter still needs to make + * its checkbox appear. The page embeds this as the poll script's + * starting point, so the very first poll tick is compared against the + * data the page was actually rendered with, rather than treating + * whatever that first tick happens to see as the baseline. + */ +function snapshotOf( + traces: readonly TraceSummary[], + selectedTypes: readonly string[], +): string { + const types = distinctActivityTypes(traces); + const rows = filterTracesByTypes(traces, selectedTypes) + .map((trace) => + `${trace.traceId}:${ + [...trace.activityTypes].sort().join("+") + }:${trace.activityCount}` + ) + .sort(); + return `${types.join(",")}|${rows.join(";")}`; +} + +/** Collects the distinct dot-joined log categories, sorted. */ +function distinctLogCategories( + logs: readonly SerializedLogRecord[], +): string[] { + const categories = new Set(); + for (const log of logs) categories.add(log.category.join(".")); + return [...categories].sort(); +} + +/** + * Criteria for narrowing down a trace's log records on the trace detail + * page. Every present field must match (AND); an absent or empty field + * is not applied. + */ +interface LogFilter { + readonly category?: string; + readonly level?: string; + readonly q?: string; +} + +/** Applies a {@link LogFilter} to a list of log records. */ +function filterLogs( + logs: readonly SerializedLogRecord[], + filter: LogFilter, +): readonly SerializedLogRecord[] { + let filtered = logs; + if (filter.category) { + const category = filter.category; + filtered = filtered.filter((log) => log.category.join(".") === category); + } + if (filter.level) { + const level = filter.level; + filtered = filtered.filter((log) => log.level === level); + } + if (filter.q) { + const needle = filter.q.toLowerCase(); + filtered = filtered.filter((log) => + log.message.toLowerCase().includes(needle) + ); + } + return filtered; +} + export function createDebugApp( pathPrefix: string, exporter: FedifySpanExporter, @@ -133,14 +230,20 @@ export function createDebugApp( app.get("/api/traces", async (c) => { const traces = await exporter.getRecentTraces(); - return c.json(traces); + const types = c.req.queries("type") ?? []; + return c.json(filterTracesByTypes(traces, types)); }); app.get("/api/logs/:traceId", async (c) => { const traceId = c.req.param("traceId"); await logStore.flush(); const logs = await logStore.get(traceId); - return c.json(logs); + const logFilter: LogFilter = { + category: c.req.query("category"), + level: c.req.query("level"), + q: c.req.query("q"), + }; + return c.json(filterLogs(logs, logFilter)); }); app.get("/traces/:traceId", async (c) => { @@ -148,11 +251,21 @@ export function createDebugApp( await logStore.flush(); const activities = await exporter.getActivitiesByTraceId(traceId); const logs = await logStore.get(traceId); + const logFilter: LogFilter = { + category: c.req.query("category"), + level: c.req.query("level"), + q: c.req.query("q"), + }; return c.html( , ); @@ -160,8 +273,15 @@ export function createDebugApp( app.get("/", async (c) => { const traces = await exporter.getRecentTraces(); + const selectedTypes = c.req.queries("type") ?? []; return c.html( - , + , ); }); diff --git a/packages/debugger/src/views/layout.tsx b/packages/debugger/src/views/layout.tsx index 038c07c96..11d20a0f1 100644 --- a/packages/debugger/src/views/layout.tsx +++ b/packages/debugger/src/views/layout.tsx @@ -57,6 +57,13 @@ export const Layout: FC> = ( pre { background: #f6f8fa; padding: 1rem; overflow-x: auto; border-radius: 6px; font-size: 0.8125rem; } .empty { color: #888; font-style: italic; } nav a { margin-right: 0.5rem; } + .filter-form { border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; } + .filter-form fieldset { border: none; padding: 0; margin: 0; } + .filter-form legend { font-size: 0.8125rem; color: #666; padding: 0; margin-bottom: 0.5rem; } + .filter-form label { display: inline-flex; align-items: center; gap: 0.3rem; margin: 0 1rem 0.5rem 0; font-size: 0.875rem; } + .filter-form select, .filter-form input[type="text"] { font-size: 0.875rem; padding: 0.25rem 0.4rem; } + .filter-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.25rem; } + .filter-actions button { font-size: 0.8125rem; padding: 0.3rem 0.75rem; cursor: pointer; } .log-table td { font-size: 0.8125rem; vertical-align: top; } .log-table time { font-family: monospace; white-space: nowrap; } .badge-debug { background: #e8e8e8; color: #666; } @@ -80,6 +87,8 @@ export const Layout: FC> = ( .detail-section h2 { border-bottom-color: #21262d; } pre { background: #161b22; } .empty { color: #9198a1; } + .filter-form { border-color: #30363d; } + .filter-form legend { color: #9198a1; } .badge-debug { background: #21262d; color: #9198a1; } .badge-info { background: #122d42; color: #58a6ff; } .badge-warning { background: #2e2a1f; color: #d29922; } diff --git a/packages/debugger/src/views/trace-detail.tsx b/packages/debugger/src/views/trace-detail.tsx index 2277709a3..711dda71b 100644 --- a/packages/debugger/src/views/trace-detail.tsx +++ b/packages/debugger/src/views/trace-detail.tsx @@ -2,6 +2,7 @@ /** @jsxImportSource hono/jsx */ import type { FC } from "hono/jsx"; import type { TraceActivityRecord } from "@fedify/fedify/otel"; +import { getLogLevels } from "@logtape/logtape"; import type { SerializedLogRecord } from "../mod.tsx"; import { Layout } from "./layout.tsx"; @@ -34,10 +35,38 @@ export interface TraceDetailPageProps { activities: TraceActivityRecord[]; /** - * The list of log records for this trace. + * The list of log records for this trace, already filtered by + * {@link selectedCategory}, {@link selectedLevel}, and + * {@link selectedQuery} when any of them is set. */ logs: readonly SerializedLogRecord[]; + /** + * The total number of log records for this trace, before filtering. + */ + totalLogCount: number; + + /** + * The distinct log categories available to filter by, derived from the + * unfiltered log set for this trace. + */ + availableCategories: readonly string[]; + + /** + * The category currently selected in the filter form, if any. + */ + selectedCategory?: string; + + /** + * The log level currently selected in the filter form, if any. + */ + selectedLevel?: string; + + /** + * The free-text search term currently entered in the filter form, if any. + */ + selectedQuery?: string; + /** * The path prefix for the debug dashboard. */ @@ -48,8 +77,20 @@ export interface TraceDetailPageProps { * The trace detail page of the debug dashboard. */ export const TraceDetailPage: FC = ( - { traceId, activities, logs, pathPrefix }, + { + traceId, + activities, + logs, + totalLogCount, + availableCategories, + selectedCategory, + selectedLevel, + selectedQuery, + pathPrefix, + }, ) => { + const filtered = Boolean(selectedCategory) || Boolean(selectedLevel) || + Boolean(selectedQuery); return (