Skip to main content

Fedify 2.4.0: FEP-ef61 portable objects, background tasks, media uploads, and inbox request reports

Fedify
English:

Fedify is a TypeScript framework for building ActivityPub servers. It implements federation details such as HTTP Signatures, JSON-LD processing, WebFinger, inbox and outbox routing, and activity delivery.

Fedify 2.4.0 is a minor release with an unusually long changelog. The main addition is support for FEP-ef61 portable objects: a Fedify server can now host actors whose identity is a DID rather than a domain, serve and verify their signed objects, and consume the portable objects of other servers. The release also adds an API for application-defined background tasks, the ActivityPub Media Upload extension, a hook that reports how every inbox request was authenticated and handled, and 410 Gone tombstones for deleted objects. Several defaults change to limit what a hostile peer can make Fedify do. Four new packages ship with this version: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, and @fedify/pglite.

Most applications can upgrade without code changes, but a few of the new defaults are behavior changes. They are listed in the Upgrading section at the end.

FEP-ef61 portable objects

An ActivityPub object's ID is an HTTP(S) URL on its server's domain. If that server shuts down, its IDs stop resolving, and an actor that moves elsewhere gets a new ID that everyone has to follow again. FEP-ef61, written by @silverpill of Mitra, proposes IDs that do not name a server at all. A portable ID is an ap: URI whose authority is a DID:

ap://did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2/actor

The same object can be stored on several servers, called gateways, each of which serves it under /.well-known/apgateway/. Because no server owns the ID, a portable actor, activity, or object is authenticated only by its FEP-8b32 Object Integrity Proof, made with a key of the DID in its ID. A portable actor lists its gateways in a gateways property, and other servers deliver to its inbox through them.

Fedify 2.4.0 implements a profile of FEP-ef61 for gateways and consumers of portable objects. It makes two deliberate choices where the FEP or the specifications it rests on leave questions open, and it leaves out a few parts of the proposal; both are described below. The new Portable objects chapter of the manual walks through running portable actors from start to finish and then describes the profile as a whole, so that other implementers can tell what to expect from a Fedify server. The request came from @anotherdoesnm in #288, which tracked the work from a roadmap to the final pieces.

Portable objects are a foundation for nomadic identity, not nomadic identity itself. Fedify serves and verifies portable objects and forwards activities between gateways, but it does not copy actors or posts between servers, rotate keys, or move an actor to another DID. Full nomadic identity is still open in #413.

Hosting a portable actor

A portable actor starts with an Ed25519 key pair. Its DID is made from the public key with exportDidKey(), and whoever holds the private key can act as the actor, so store it with the actor and keep it safe:

import { 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 that the actor's proofs refer to:
const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);

The actor dispatcher returns a portable actor as it would an ordinary one. Context.getPortableActorUri() and its siblings for inboxes, outboxes, collections, and objects build portable IDs from the dispatcher's paths and the DID, and signObject() signs the actor with the DID's key:

federation
 .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
 const user = await findUser(identifier);
 if (user == null) return null;
 // A request through the gateway endpoint names the DID in its path,
 // e.g., GET /.well-known/apgateway/did:key:z6Mk.../users/alice:
 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({
 id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice
 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,
 new URL(`${user.did}#${user.did.slice("did:key:".length)}`),
 );
 })
 .setKeyPairsDispatcher(async (ctx, identifier) => {
 const user = await findUser(identifier);
 // This server's own key pair for the actor, not the DID's:
 return user == null ? [] : [user.gatewayKeyPair];
 })
 .mapPortableActorId(async (ctx, identifier) => {
 const user = await findUser(identifier);
 return user == null ? null : ctx.getPortableActorUri(identifier, user.did);
 });

With this dispatcher, Fedify serves the actor at its compatible identifier, https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice, which is how other servers retrieve it. It serves it through WebFinger as @alice@example.com, since the handle's domain comes from the first gateway in gateways, and at its ordinary URL as before. On the gateway endpoint, Fedify serves a document only if its ID is the requested portable ID and it carries a proof made with a key of that DID.

The key pairs dispatcher and the new ActorCallbackSetters.mapPortableActorId() give the actor gateway keys. These are the server's own keys for the actor, and they sign only the HTTP requests the server makes on the actor's behalf, such as deliveries. They never sign the actor's activities or objects; only the DID's key does that. The actor document lists them under assertionMethods, as FEP-521a describes, so that other servers can verify the HTTP Signatures. See Dispatching the actor and Gateway keys of portable actors for the details.

Object and collection dispatchers serve portable objects and collections through the gateway endpoint in the same way, routed by the path after the DID. Two rules protect the actor's data. A non-public portable object is served only if the dispatcher has an authorization predicate, because FEP-ef61 forbids a gateway to show it to anyone outside its audience; the new RequestContext.isSignedByAudience() helps to write one. Without a predicate, a request for such an object gets 404 Not Found, as though the gateway did not store it. A deleted object is served as a signed Tombstone with 410 Gone. Resources that portable objects refer to by SHA-256 hashlinks, such as GET /.well-known/apgateway/hl:zQm..., are served by the new Federatable.setHashlinkMediaDispatcher().

Sending and receiving

A portable actor's activities have to be signed by its DID, which Fedify's key pairs dispatcher does not know about. Sign them with signObject() before sending, and pass normalizeExistingProofs: true so that the activity goes out in the form its proof covers. An activity that embeds a portable object, such as the Note of a Create, is signed inside out:

const actor = withGatewayHints(
 ctx.getPortableActorUri(user.identifier, user.did),
 [new URL("https://example.com")],
); // ap+ef61://did:key:z6Mk.../users/alice?@gateway=https%3A%2F%2Fexample.com
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,
 didKeyId,
);
const create = await signObject(
 new Create({
 id: ctx.getPortableObjectUri(
 Note,
 { identifier: user.identifier, id: crypto.randomUUID() },
 user.did,
 ),
 actor,
 to: PUBLIC_COLLECTION,
 object: note,
 }),
 user.didKeyPair.privateKey,
 didKeyId,
);
await ctx.sendActivity(
 { identifier: user.identifier },
 "followers",
 create,
 { normalizeExistingProofs: true },
);

This example depends on a fix to signObject() that is useful outside FEP-ef61 as well. Previously, a signed object assigned to a typed parent was rebuilt under the parent's JSON-LD context when the parent was serialized. The child kept its proofValue but lost the document its proof covered, so it no longer verified on its own. signObject() now captures the exact document it signed, and nested serialization embeds that document verbatim. The captured document is a snapshot: clone() does not carry it, so sign a clone again if it has to be embedded. The design is in #1044 and the fix in #1051.

Context.sendActivity() refuses a portable activity that is unsigned, signed by the wrong key, or carries a Linked Data Signature, and throws a TypeError before anything is queued. It also refuses a few shapes of compound document that Fedify's own inboxes would reject, such as a received signed Follow embedded in an Accept; refer to such an object by its ID instead. Deliveries to portable inboxes go through the recipient's gateways, or else the @gateway hints on the inbox, trying at most five of them until one accepts. Before this release, they failed with UrlError: Unsupported protocol: ap+ef61:. The rules are in Delivering to portable actors and Producing a compound document.

On the receiving side, a portable inbox is reached as POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox and dispatched to the same inbox listeners as the ordinary inbox. Context.parseUri() gains a portable option, and the parsed result has the DID in a new authority property to check against the one you store. Fedify accepts an activity of a portable actor, or one with a portable ID, only if it has a valid proof by that DID; neither an HTTP Signature nor a Linked Data Signature authenticates it. Each portable object embedded in the activity is verified against its own proof before dispatch. An accepted activity that carries its own proof is forwarded to the actor's other gateways at most once, as FEP-ef61 recommends. Forwarding needs a key–value store that supports cas(), and the FederationOptions.portableInboxForwarding option tunes it. See Portable inboxes and Forwarding to other gateways.

Consuming portable objects

Applications that never host a portable actor still meet portable objects from other servers. Context.lookupObject() resolves portable IDs, compatible identifiers, and the fediverse handles of portable actors, and returns portable objects only if they verify:

const actor = await ctx.lookupObject("@alice@example.com");
// A portable ID without @gateway hints needs gateways to ask:
const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", {
 gateways: ["https://example.com"],
});

Property accessors fetch portable references through gateways as well, and they verify what they fetch with the new verifyPortableObject option. Context has a matching verifyPortableObject property, so passing a context as the options, as in await create.getObject(ctx), is enough. Activities received in inboxes use this verifier by default, and objects fetched through their accessors inherit it, so a chained call such as (await create.getObject(ctx))?.getAttribution() verifies each portable object along the way. A lookup asks at most five gateways, falling back to the next when one fails or serves something that does not verify. Unsigned collections are accepted only from a gateway that the owner lists, and only for an actor's inbox, outbox, followers, following, and liked, as FEP-ef61 allows. fetchPortableMedia() retrieves the media of a portable object and returns it only if it matches the object's digestMultibase. See Dereferencing portable references and Default verifiers.

Compatible identifiers are no longer trusted by origin

Software that does not understand ap: URIs can refer to a portable object by a compatible identifier, an HTTP(S) URL under a gateway's /.well-known/apgateway/ path such as https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor. Some implementations, tootik among them, use compatible identifiers as the IDs of their portable actors.

Earlier versions of Fedify treated these URLs as ordinary web URLs and trusted them by the server that served them. Since anyone can serve a compatible identifier for any DID, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor could act as Alice without a proof by Alice's DID. Fedify 2.4.0 treats a compatible identifier as the portable object it stands for, as FEP-ef61 requires. Inboxes reject an activity from an actor whose ID is a compatible identifier with 401 Unauthorized unless it has a valid Object Integrity Proof by the actor's DID, and there is no option to turn this off. Actors that publish compatible identifiers without signing them are no longer accepted, and neither are those whose DIDs use key types that Fedify cannot verify yet. The change was tracked in #1093 and made in #1105.

Your own portable actors may still use compatible identifiers as their IDs, for software that rejects ap: IDs. Build them with toCompatibleEf61Id() before signing, and Fedify will treat them as portable wherever it serves or sends them. It also warns if one is malformed or does not point at the actor's first gateway. See Compatible identifiers as actor IDs.

Where Fedify's profile differs from the FEP

Two choices in Fedify's profile differ from the current FEP text or rest on semantics that are not settled yet. Fedify keeps both for the 2.x series, and either may change in Fedify 3.0.

The first is the scheme. FEP-ef61 recommends ap: and canonicalizes ap+ef61: to it, while also warning that the recommendation may change to ap+ef61:, since these IDs are meant only for portable objects. Fedify accepts both, compares them as equal, and serializes ap+ef61:. Parsing a document normalizes its portable IRIs to ap+ef61:, so re-serializing an object that another implementation signed with ap: IDs breaks its proof. Keep the received JSON of such documents, and compare portable IDs with arePortableUrisEqual() rather than as strings. The discussion is in #826 and #828, and the reasoning is in Canonical ap+ef61: scheme.

The second concerns compound documents. When a signed Note sits inside a signed Create, the verifier has to know which part of the JSON each proof covers, and neither FEP-8b32 nor Verifiable Credential Data Integrity defines that yet; the question is open in w3c/vc-data-integrity#350. Fedify uses an interim map-local profile, worked out in #938. Each JSON map with a proof is its own secured document, each embedded portable object needs its own proof and its own @context, and proof sets and chains are not supported. The profile authenticates the exact JSON values each proof covers; it does not promise that a child's JSON-LD expansion inside its parent matches its expansion on its own. @silverpill, @dmitrizagidulin, and @by_caballero reviewed the proposal in that thread, and @silverpill reproduced its test vectors independently. See Map-local compound proofs.

What is not implemented

Fedify does not implement FEP-ae97, the companion proposal for client-side signing. There are no endpoints for registering actors, submitting client-signed activities to a portable outbox, or gateway discovery, and no media upload and deletion endpoints under /.well-known/apgateway-media. Your own outbox listeners can still relay activities that a client signed, unchanged. Fedify also does not copy or reconcile actors, objects, or collections between gateways, so running an actor on several gateways means keeping their data in sync yourself. There is no workflow for rotating a DID's key or migrating an actor to another DID. Fedify resolves verification methods only for did:key DIDs with Ed25519 keys; other DID methods verify only if your document loader resolves them. Gateways must be plain origins, since the arbitrary gateway paths that FEP-ef61 discusses are not supported. The full list is in Not supported.

Interoperability

The profile was tested against tootik v0.25.4 and Mitra v5.10.0 in #1202. Fedify verifies tootik's portable actors, activities, and HTTP Signatures, and Mitra's gateway HTTP Signatures on activities that a client signed. Both accept the portable activities that Fedify sends. Two fixes came out of this testing. Fedify now reads a gateways list that tootik leaves unmapped in its JSON-LD context. It also serializes gateways as bare origins, because Mitra rejects a trailing slash. Fedify has not yet verified an actor or activity that Mitra signed itself, and Mastodon's handling of portable actors has not been tested. The FEP-ef61 interoperability section of FEDERATION.md records what was tested and how.

Tooling

fedify lookup looks up portable objects by their ap: and ap+ef61: IDs, compatible identifiers, and the handles of portable actors, and it verifies their proofs. When no gateway returns an acceptable object, it says why. A new --gateway option on fedify lookup, fedify inbox, and fedify webfinger names the gateways to ask. fedify inbox --follow follows portable actors, and fedify webfinger accepts portable actor IDs. See Looking up portable objects in the CLI manual.

The mock federation and contexts in @fedify/testing support portable IDs, verification against fixture document loaders, gateway requests, and hashlink media, so tests can exercise portable objects without a live gateway. @fedify/lint's actor URI rules accept the portable IDs that the getPortable*Uri() methods build. @fedify/next now passes hashlink media requests to Fedify, a problem reported in #1149 and fixed in #1170; to have Next.js run the middleware for them, add { source: "/.well-known/apgateway/:path*" } to your matcher.

Background tasks

Fedify already runs activity delivery and inbox processing on queue workers, but applications usually have jobs of their own: digest emails, timeline rebuilds, link previews. Until now, those jobs could not use Fedify's queues, serialization, or retries. Fedify 2.4.0 opens the same machinery to application-defined tasks.

A task is defined once with defineTask() and enqueued from any Context with enqueueTask() or enqueueTaskMany(). Every task takes a Standard Schema, such as a Zod or Valibot schema, which types its payload. Fedify validates the payload when it is enqueued and again when a worker picks it up; the second check catches payloads from an earlier deployment that no longer match the schema:

import { createExponentialBackoffPolicy } from "@fedify/fedify";
import { z } from "zod";

const sendDigest = federation.defineTask("sendDigest", {
 schema: z.object({ userId: z.string(), since: z.date() }),
 handler: async (ctx, data) => {
 // data is typed as { userId: string; since: Date }
 await sendEmail(data.userId, await buildDigest(data.userId, data.since));
 },
 retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),
});

// Anywhere with a Context:
await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, {
 delay: { minutes: 30 },
 deduplicationKey: "digest:alice",
});

Fedify serializes payloads with devalue, so Date, Map, Set, URL, bigint, Temporal values, circular references, and Activity Vocabulary objects all survive the trip through any message queue backend. A Note in a payload comes back as a Note. Failed handlers are retried with exponential backoff by default, and queues with native retries handle retries themselves. Tasks fall back to the outbox queue unless you give them their own. Use the new task slot of the queue option or a per-task queue for that, and startQueue(ctxData, { queue: "task" }) runs a task-only worker. Setting taskQueueResolution: "strict" turns the fallback into an error.

A deduplicationKey deduplicates enqueues. The feature came from @julian's point in #206 that recurring tasks are hard to manage when you cannot tell whether one is already queued. A queue that declares nativeDeduplication enforces the key itself; otherwise, Fedify keeps a best-effort marker in the key–value store through cas(). Each task runs in a fedify.task span, and the queue metrics from 2.3.0 now include task queues. The Background tasks chapter covers routing, retries, deduplication, and the limitations; cron-style scheduling, result backends, and priorities are out of scope for now.

ChanHaeng Lee (@2chanhaeng) implemented the API: the core in #803, deduplication in #806, and observability in #812, following the plans in #797, #798, and #799. The work was merged in #923, closing #206, which had been open since February 2025.

Media uploads

Fedify 2.2.0 added client-to-server outbox listeners, but a C2S client also has to upload images and video before it can post them. That traffic does not go through POST /outbox. Fedify 2.4.0 supports the ActivityPub Media Upload extension, a SocialCG proposal last updated in 2017 that never reached W3C Recommendation status.

setMediaUploader() registers a multipart/form-data endpoint. Its callback receives the uploaded file and the object shell the client posted along with it. If the callback returns the created object, Fedify responds with 201 Created; if it returns a URL where the object will appear once processing finishes, Fedify responds with 202 Accepted:

federation
 .setMediaUploader(
 "/users/{identifier}/media",
 async (ctx, identifier, file, object) => {
 const stored = await uploadToStorage(file);
 return new Image({
 id: ctx.getObjectUri(Image, { uuid: stored.uuid }),
 url: new URL(stored.publicUrl),
 mediaType: file.type,
 name: object.name,
 });
 },
 )
 .authorize(async (ctx, identifier) => {
 const session = await verifyAccessToken(
 ctx.request.headers.get("authorization"),
 );
 return session?.identifier === identifier;
 });

The actor dispatcher advertises the endpoint itself by setting endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }), the same way it places its inbox and outbox URIs. Without an authorize() hook, anyone who finds the endpoint can upload to it. For that reason, Fedify logs a warning when an uploader is registered without one, and when the returned object's URI does not match an object dispatcher or the actor does not advertise the endpoint. Four new @fedify/lint rules catch the same mistakes before they run. Fedify reads the upload into memory and sets no size limit of its own, so cap the request size at your reverse proxy. Storing the file and serving the object back are up to the application. See the Media upload chapter; the work is in #754 and #927.

Inbox request reports

Fedify decides several things about every inbox delivery: which signature mechanisms applied, which keys they tried, whether the actor owned the key, and what happened to the activity. Before this release, applications saw only part of that. A Linked Data Signature by a key the actor did not own was counted the same as a bad signature, and the key that verified a request was never handed to the application. The request in #1191 came from an effort to log these decisions for a debugging tool.

The new onRequestFinished() callback on inbox listeners receives an InboxRequestReport for every delivery to an inbox route, including rejected requests and requests that fail before verification:

federation
 .setInboxListeners("/users/{identifier}/inbox", "/inbox")
 .on(Follow, async (ctx, follow) => { /* ... */ })
 .onRequestFinished(async (ctx, report) => {
 await db.insertInboxLog({
 inbox: report.inbox.kind, // "personal" | "shared" | "portable"
 authentication: report.authentication.status,
 outcome: report.outcome.type === "response"
 ? report.outcome.disposition // "processed" | "enqueued" | "duplicate" | ...
 : `exception:${report.outcome.stage}`,
 keyIds: report.attempts
 .flatMap((attempt) => attempt.checks)
 .flatMap((check) => check.triedKeys)
 .map((key) => key.id?.href),
 });
 });

Each HTTP Signature, Linked Data Signature, or Object Integrity Proof check appears as an attempt with the actual keys it tried, including a stale cached key and its refreshed replacement. The report also carries the final authentication decision, such as actorKeyMismatch or proofPolicy, and the response or exception that ended the request. Fedify awaits the callback whether or not the trace is sampled. Errors it throws are logged without changing the response, and enqueued means the queue accepted the activity, not that a worker later processed it. Mock federations in @fedify/testing accept the same callback. See Observing inbox requests; the implementation is in #1201.

Tombstones for deleted objects

Actor dispatchers have been able to return a Tombstone since 2.2.0, but object dispatchers could only return null. A deleted post was answered with 404 Not Found, as if it had never existed, so applications that wanted 410 Gone, as ActivityPub recommends, needed their own route in front of Fedify.

Object dispatchers may now return a Tombstone, which Fedify serves with 410 Gone and the serialized tombstone after applying the authorization predicate:

federation.setObjectDispatcher(
 Note,
 "/users/{identifier}/notes/{id}",
 async (ctx, values) => {
 const post = await getPost(values.identifier, values.id);
 if (post == null) return null;
 if (post.deletedAt != null) {
 return new Tombstone({
 id: ctx.getObjectUri(Note, values),
 formerType: Note,
 deleted: post.deletedAt,
 });
 }
 return new Note({ id: ctx.getObjectUri(Note, values), content: post.content });
 },
);

The return type of ObjectDispatcher widens to TObject | Tombstone | null. RequestContext.getObject() still returns null for a tombstone unless you pass { tombstone: "passthrough" } or ask for the Tombstone or Object class itself. Dispatchers registered for Tombstone or Object that already returned tombstones now respond with 410 rather than 200. See Deleted objects; the change was proposed in #1112 and made in #1117.

Limits on what a remote peer can make Fedify do

Much of what Fedify fetches is chosen by the sender of an incoming request. Key IDs in signatures are one example, and the servers behind them can be slow on purpose. Several changes in 2.4.0 bound that work.

As #1130 points out, a request may carry several RFC 9421 signatures, and each one can make Fedify fetch a key from a URL of the sender's choosing. Fedify now verifies at most the first three, in Signature-Input order, and ignores the rest. A signature counts toward the limit even if it fails before its key is fetched, and a key named by several signatures is looked up once. The new FederationOptions.maxHttpSignatures option, added with the limit in #1166, changes it.

The built-in document loaders now give each call ten seconds by default, a limit proposed in #1131 and added in #1169. The time limit covers every redirect, alternate document, double-knocking retry, and the response body. A timed-out call throws a FetchError whose cause is a DOMException named "TimeoutError". Error responses are read in full before the loader throws, up to 1 MiB, so that a server cannot send a 404 header and then stall the body. FederationOptions.documentLoaderTimeout changes the limit or turns it off with null, and custom document loaders are not affected. Since #913, getDocumentLoader() also rejects HTML responses that do not advertise an ActivityPub alternate with a FetchError, rather than failing inside JSON.parse(), which #912 found cluttering error trackers.

Cached public keys and remembered HTTP Message Signatures specs used to live forever in the key–value store. #1017 reports a production Redis instance holding 194,424 public-key entries, about 165 MiB, for actors that had mostly stopped federating. Keys now expire after 30 days and specs after 90, configurable through FederationOptions.publicKeyTtl and FederationOptions.httpMessageSignaturesSpecTtl. Entries written by earlier versions have no expiry until they are next written. The Clearing legacy cache entries section shows how to remove them for Redis, PostgreSQL, and other stores. Heewon Chae (@heeoneie) implemented this in #1027, their first contribution to Fedify.

const federation = createFederation<void>({
 kv,
 maxHttpSignatures: 1, // default: 3
 documentLoaderTimeout: { seconds: 5 }, // default: 10 seconds
 publicKeyTtl: { days: 7 }, // default: 30 days
 httpMessageSignaturesSpecTtl: { days: 30 }, // default: 90 days
});

Finally, verifyProof() now authenticates every option of an Ed25519 JCS proof except proofValue, including expires, domain, challenge, and nonce. It also rejects expired or malformed ones. Previously, a field such as expires could be added to an already signed proof without invalidating it. Jiwon Kwon (@z9mb1) spotted this in review of #968. Callers can now require a domain and challenge through VerifyProofOptions to prevent cross-domain or replayed use.

New packages

@fedify/adonisjs

@fedify/adonisjs integrates Fedify with AdonisJS. Running node ace add @fedify/adonisjs registers a service provider that owns the federation's lifecycle and a server middleware that mounts it. It also scaffolds config/fedify.ts, start/federation.ts, and app/federation/main.ts. Controllers get a ctx.federation request context:

// app/federation/main.ts
import federation from "@fedify/adonisjs/services/builder";
import { Person } from "@fedify/vocab";

federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) =>
 new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));

The middleware sits first in AdonisJS's server stack rather than its router stack. That way Fedify sees paths the router does not know, such as /.well-known/webfinger, and reads request bodies before the body parser consumes them. The package targets Node.js and is published to npm only. See the AdonisJS section of the integration guide.

Samuel Brinkmann (@sabrinkmann) contributed the package in #1006, closing #139, and built it on a prototype that Emelia Smith (@thisismissem) attached to that issue.

npm add @fedify/adonisjs
pnpm add @fedify/adonisjs
yarn add @fedify/adonisjs

@fedify/interaction-controls

GoToSocial interaction controls, FEP-044f quote authorization, and FEP-7aa9 featured collections share a request-and-authorization flow. A server asks to reply to, quote, or feature a post, and the author's server approves according to the post's interactionPolicy. @fedify/interaction-controls implements that flow for like, reply, announce, quote, and feature interactions. Each has a helper, such as likeInteraction or quoteInteraction, that creates and verifies requests and authorizations, evaluates policies, and recognizes interactions that arrived without a request:

import { likeInteraction } from "@fedify/interaction-controls";

// When a LikeRequest arrives:
const verified = await likeInteraction.verifyRequest(ctx, { request });
if (verified.verified) {
 const decision = await likeInteraction.evaluatePolicy(ctx, {
 subject: note,
 requester: request.actorId!,
 });
 if (decision.result === "automatic") { // or "manual" or "denied"
 const authorization = likeInteraction.createAuthorization({
 id: new URL("https://example.com/authorizations/1"),
 attributedTo: owner,
 interactingObject: like,
 interactionTarget: note,
 });
 // ...
 }
}

The package does not install inbox listeners or store anything; the application decides where requests and authorizations live and when a human needs to approve. Verification fails closed. Without a policy, likes, replies, and announces are approved automatically, while quotes and features are denied. @fedify/vocab gains the FEP-7aa9 types this needs: FeaturedCollection, FeaturedItem, FeatureRequest, and FeatureAuthorization, along with actors' featuredCollections and InteractionPolicy.canFeature. Samuel Brinkmann asked for them in #810 once Mastodon 4.6 shipped the FEP. See the Interaction controls chapter; the work is in #914 and #929.

npm add @fedify/interaction-controls
pnpm add @fedify/interaction-controls
yarn add @fedify/interaction-controls
deno add jsr:@fedify/interaction-controls
bun add @fedify/interaction-controls

@fedify/netlify

@fedify/netlify lets a Fedify application run on Netlify Functions without an external database or queue. NetlifyMessageQueue submits jobs as Netlify Async Workloads events, and createNetlifyQueueHandler() turns a Function into their consumer, with delayed delivery, native retries, and per-key FIFO ordering. NetlifyBlobsKvStore is a key–value store on Netlify Blobs with expiration, prefix listing, and compare-and-swap:

import {
 createNetlifyQueueHandler,
 NetlifyBlobsKvStore,
 NetlifyMessageQueue,
} from "@fedify/netlify";
import { AsyncWorkloadsClient } from "@netlify/async-workloads";
import { getStore } from "@netlify/blobs";

export const kv = new NetlifyBlobsKvStore(
 getStore({ name: "fedify", consistency: "strong" }),
);
export const queue = new NetlifyMessageQueue({
 client: new AsyncWorkloadsClient(),
 orderingKv: kv,
});

// netlify/functions/fedify-queue.ts
export default createNetlifyQueueHandler({
 queue,
 maxRetries: 4,
 federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),
});

The key–value store exists because of cost. #1010 describes a static blog whose ActivityPub traffic kept a Netlify Database from ever idling, which used most of the account's monthly credits. Jiwon Kwon contributed NetlifyBlobsKvStore in #1029, and the queue landed in #934. See Netlify Functions in the deployment guide.

npm add @fedify/netlify
pnpm add @fedify/netlify
yarn add @fedify/netlify
deno add jsr:@fedify/netlify
bun add @fedify/netlify

@fedify/pglite

@fedify/pglite provides PgliteKvStore, a KvStore on an embedded PGlite database that you create and pass in. It uses the same schema as PostgresKvStore. It is meant for a single PGlite instance in a single runtime isolate, so it does not include a message queue: PGlite does not share data between processes.

import { PGlite } from "@electric-sql/pglite";
import { PgliteKvStore } from "@fedify/pglite";

const federation = createFederation({
 kv: new PgliteKvStore(new PGlite("./data/fedify")),
});

ChanHaeng Lee contributed the package in #1020, together with testKvStore() in @fedify/testing. testKvStore() is a conformance suite for KvStore implementations, the counterpart to testMessageQueue(). See PgliteKvStore.

npm add @fedify/pglite
pnpm add @fedify/pglite
yarn add @fedify/pglite
deno add jsr:@fedify/pglite
bun add @fedify/pglite

Compare-and-swap for PostgreSQL and Redis

Portable inbox forwarding, task deduplication, Netlify queue ordering, and the delivery circuit breaker all need an atomic compare-and-swap on the key–value store. PostgresKvStore now implements cas(), added in #934, and so does RedisKvStore, requested in #1163 and added in #1167. The Redis version uses a single-key Lua script, which works on standalone Redis and Redis Cluster. Before this, the circuit breaker fell back to racy updates on Redis.

Both come with conditions. RedisKvStore needs Redis to permit EVAL, and custom codecs must encode equal values identically. PostgresKvStore now creates logged tables by default, because losing sequence state in crash recovery could block queued events for good. It migrates existing unlogged tables when it initializes. That one-time migration rewrites the table and locks it exclusively, so schedule the upgrade accordingly if the table is large or busy; unlogged: true keeps the old behavior.

fedify init and the CLI

fedify init can now scaffold a SvelteKit project, contributed by Jang Hanarae (@menele) in #971. Jang Hanarae also added a test task to generated projects in #990. It starts the app, waits for it to be ready, and checks that it resolves a local actor, which gives new projects a smoke test to run after scaffolding and after later changes. Before generating anything, fedify init now checks that the selected Deno, Bun, or Node.js meets Fedify's minimum version, or a framework's higher one, such as Astro's Node.js 22.12. It fails with a clear error in non-interactive mode, and in interactive mode it disables the affected package managers. Lee Jeongmin (@userjmmm) contributed the check in #981. Astro projects now use Astro 7, and since #936 @fedify/astro is tested against Astro 5, 6, and 7 with real builds on Node.js, Deno, and Bun, as #931 asked.

fedify tunnel gains fedify.com.es, a tunneling service run by the Fedify project that needs no account; the CLI pins its SSH host key and refuses a mismatched server before exposing a local port. localhost.run is removed in the same change, #940, because the service no longer exists. fedify lookup now times out each request after ten seconds when -T/--timeout is not given, and the option now also bounds the wait for a DNS lookup. The CLI moves to Optique 1.3.2 in #1197, which handles attached values like --timeout=30 consistently, gives better suggestions for mistyped options, and no longer hides errors for known options behind positional arguments before --.

Lint rules

@fedify/lint gains several rules beyond the media upload ones mentioned above. actor-preferred-username-required, requested in #895 and added in #1022, warns when an actor dispatcher returns an actor without preferredUsername. outbox-listener-delivery-not-awaited, from #1057 and #1067, reports an outbox listener that calls ctx.sendActivity() or ctx.forwardActivity() and drops the promise. On Cloudflare Workers, such an activity may never leave, because pending work is discarded once the response returns. await, return, Promise.all(), Promise.allSettled(), and waitUntil() count as handling the promise, while void, Promise.race(), and Promise.any() are accepted as a deliberate choice not to wait. The rule is a warning in the ESLint recommended configuration and an error in strict. Oxlint users enable it by name, and it is not available in Deno Lint.

In #1050, which resolves #900, the existing outbox-listener-delivery-required rule began following control flow instead of scanning the listener's source as text. It reports a listener whose only delivery calls sit in a dead branch, after an unconditional return or throw, or in a function that is never used, and it stays quiet when it cannot tell. Jae-Hyuk-Jang (@jhyuk) contributed this change and both new rules. archie (@ArchieTansaria) then taught both delivery rules about statically false loops and loop binding patterns in #1088. See the linting guide.

Other changes

  • @fedify/vocab supports the FEP-22cd draft for translations. A Translation records a translated version's language, translators, source object, and source review time, and the new Object.translations property attaches them to a post without creating separate posts. See Translation metadata. (#1037, #1038)
  • @fedify/vocab-tools gains an extraContext property option, which adds a JSON-LD context only when its terms are used, and a trustEmbeddedObjects type option. (#1037, #1038)
  • Vocabulary fetch and parse failures that suppressError: true handles are now logged as warnings rather than errors, so that they no longer show up as application errors. Contributed by Jae Hui Hong (@axz0612). (#933, #1035)
  • A queue whose enqueueMany() makes separate sends can set MessageQueue.atomicEnqueueMany to false. Fedify then rejects a multi-message batch governed by one deduplicationKey up front, since a partial send followed by a retry could enqueue duplicates. (#930, #934)
  • CommonJS builds that use Temporal bundle temporal-polyfill instead of requiring @js-temporal/polyfill at runtime, and type declarations rely on the standard esnext.temporal lib. (#823, #925)
  • The document loaders' Accept header now includes the ActivityStreams profile on the JSON-LD media type, as ActivityPub and FEP-ef61 gateways require. (#834, #1077)
  • The relay documentation now uses the canonical actor and shared inbox URIs, separates Mastodon-style and LitePub-style subscriptions, and explains what remains the application's responsibility. Contributed by Jiwon Kwon. (#899, #996)

Bug fixes

  • Fixed outbound delivery circuit breaker transitions on key–value stores that compare encoded values. Existing half-open states can recover after switching to a CAS-capable store. (#1163, #1167)
  • Fixed fedify lookup --recurse reporting a timeout or other network failure as a possibly private object; it now reports the actual cause. (#1156, #1199)
  • Fixed the -p/--allow-private-address option of fedify webfinger being ignored. (#1156, #1199)
  • Fixed outbox-listener-delivery-required and outbox-listener-delivery-not-awaited losing track of an object's functions when a nested helper assigns another property of that object. (#1125, #1140)

Upgrading

npm add @fedify/fedify@2.4.0
pnpm add @fedify/fedify@2.4.0
yarn add @fedify/fedify@2.4.0
deno add jsr:@fedify/fedify@2.4.0
bun add @fedify/fedify@2.4.0

Update the other @fedify/* packages you use to 2.4.0 at the same time. To update the CLI, run npm install -g @fedify/cli@2.4.0, or see the installation instructions for other package managers.

Most applications need no code changes, but these changes in behavior may affect you:

  • Activities from actors whose IDs are FEP-ef61 compatible identifiers are rejected with 401 Unauthorized unless they carry a valid Object Integrity Proof by the actor's DID. Keys at compatible identifiers, and keys at ordinary URLs that claim a portable owner, are no longer trusted by origin.
  • Property accessors given a Context as their options, such as await create.getObject(ctx), now verify the portable objects they dereference, including references to compatible identifiers from objects with ordinary HTTP(S) IDs.
  • Your dispatchers may be called for requests to /.well-known/apgateway/, and Context.sendActivity() may reject an activity that embeds others' portable objects. Next.js applications need the matcher shown above to serve hashlink media.
  • Only the first three RFC 9421 signatures of a request are verified. Set maxHttpSignatures if you need more.
  • The built-in document loaders time out after ten seconds, and fedify lookup does too.
  • Cached public keys expire after 30 days and HTTP Message Signatures specs after 90 days.
  • Object dispatchers registered for Tombstone or Object that return tombstones now respond with 410 Gone instead of 200 OK.
  • PostgresKvStore migrates existing unlogged tables to logged tables on initialization, which locks the table.
  • RedisKvStore needs EVAL permission for cas().
  • Queue workers from older versions deliver queued activities for portable inboxes only through the first gateway. Upgrade workers before the servers that enqueue deliveries.
  • Custom implementations of the Context and RequestContext interfaces need the new getPortable*Uri() methods and isSignedByAudience().
  • fedify tunnel no longer supports localhost.run.

Acknowledgments

Thanks to this release's contributors:

  • Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, and the request for FEP-7aa9 vocabulary
  • Jang Hanarae (@menele): SvelteKit support and the test task in fedify init
  • Lee Jeongmin (@userjmmm): runtime version verification in fedify init
  • Jae-Hyuk-Jang (@jhyuk): the actor-preferred-username-required and outbox-listener-delivery-not-awaited lint rules, and control-flow analysis for outbox-listener-delivery-required
  • archie (@ArchieTansaria): loop handling in the outbox delivery lint rules
  • Jae Hui Hong (@axz0612): warning-level logging for suppressed vocabulary failures
  • Heewon Chae (@heeoneie): expiring public key and HTTP Message Signatures spec caches

ChanHaeng Lee (@2chanhaeng) and Jiwon Kwon (@z9mb1) joined as maintainers after 2.3.0. ChanHaeng built the background task API and @fedify/pglite, Jiwon built NetlifyBlobsKvStore and rewrote the relay documentation, and both reviewed much of the rest of this release.

Many people added tests, documentation, and infrastructure during this cycle, often starting from a good first issue: Seoa Yoon (@bananamilk452), Lim Kyoujin (@dktsudgg), @m2nsp, Cho Seongmin (@seongmin36), Safal Shrestha (@Safal-Shrestha-SS), Jo Youngjae (@jaonz6057), @kecan0406, Loi Nguyen (@lntutor), Oh Taejun (@0w0), Yerin Park (@dstforye), Lee Dogeon (@moreal), and Sae Jin Kim (@siliconsjang).

The FEP-ef61 work owes a lot to people outside the project. @anotherdoesnm opened the request in #288. @silverpill, the author of FEP-ef61 and FEP-ae97, clarified the gateway trust model, reviewed the compound proof profile, and checked its test vectors. @dmitrizagidulin and @by_caballero weighed in on the profile's JSON-LD questions in #938. Testing against tootik and Mitra led to two of the interoperability fixes in this release.

See CHANGES.md for the complete changelog.

日本語:

自動翻訳 · 原文を読む


Fedify は、ActivityPubサーバーを構築するためのTypeScriptフレームワークです。HTTP Signatures、JSON-LD処理、WebFinger、インボックスおよびアウトボックスのルーティング、アクティビティ配信といったフェデレーションの詳細を実装しています。

Fedify 2.4.0はマイナーリリースですが、例外的に長い変更履歴を含んでいます。主な追加機能は FEP-ef61 ポータブルオブジェクトのサポートです。Fedifyサーバーは、ドメインではなくDIDをIDとするアクターをホストし、その署名済みオブジェクトの配信および検証を行い、他のサーバーのポータブルオブジェクトを使用できるようになりました。また、本リリースでは、アプリケーション定義のバックグラウンドタスク用API、ActivityPub Media Upload拡張、各インボックスリクエストの認証および処理方法を報告するフック、削除されたオブジェクトに対する 410 Gone トゥームストーンも追加されています。さらに、悪意のあるピアがFedifyに及ぼし得る影響を制限するため、いくつかのデフォルト設定が変更されました。このバージョンでは、@fedify/adonisjs、@fedify/interaction-controls、@fedify/netlify、@fedify/pglite の4つの新しいパッケージが提供されます。

ほとんどのアプリケーションはコードを変更することなくアップグレード可能ですが、一部の新しいデフォルト設定によって動作が変更されます。これらは末尾の アップグレード セクションに記載されています。

FEP-ef61ポータブルオブジェクト

ActivityPubオブジェクトのIDは、そのサーバーのドメイン上にあるHTTP(S) URLです。そのサーバーが停止するとIDの解決ができなくなり、別の場所に移動したアクターは新しいIDを取得するため、すべてのユーザーが再度フォローし直さなければならなくなります。Mitra の @silverpill によって作成された FEP-ef61 は、サーバーを一切指定しないIDを提案しています。ポータブルIDは、オーソリティが DID である ap: URIです。

ap://did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2/actor

同一のオブジェクトを、ゲートウェイと呼ばれる複数のサーバーに保存できます。各ゲートウェイは、それを /.well-known/apgateway/ 配下で提供します。どのサーバーもそのIDを所有していないため、ポータブルなアクター、アクティビティ、またはオブジェクトは、ID内のDIDの鍵によって作成された FEP-8b32 Object Integrity Proofによってのみ認証されます。ポータブルアクターは gateways プロパティで自身のゲートウェイを一覧表示し、他のサーバーはそれらを通じてそのインボックスへ配信を行います。

Fedify 2.4.0は、ポータブルオブジェクトのゲートウェイおよびコンシューマー向けに、FEP-ef61のプロファイルを実装しています。FEPまたはそれが依拠する仕様において未解決となっている部分について、Fedifyは意図的な2つの選択を行っており、また提案の一部を意図的に省略しています。これらはいずれも後述します。マニュアルの新しい ポータブルオブジェクト の章では、ポータブルアクターの運用について最初から最後まで順を追って説明し、その後プロファイル全体について解説しているため、他の実装者はFedifyサーバーに期待される動作を把握できます。このリクエストは、ロードマップから最終段階までの作業を追跡した #288 において @anotherdoesnm から寄せられたものです。

ポータブルオブジェクトはノーマディックアイデンティティ(遊牧的アイデンティティ)の基盤であり、ノーマディックアイデンティティそのものではありません。Fedifyはポータブルオブジェクトの提供と検証を行い、ゲートウェイ間でアクティビティを転送しますが、サーバー間でのアクターや投稿のコピー、鍵のローテーション、あるいは別DIDへのアクターの移行などは行いません。完全なノーマディックアイデンティティへの対応は、現在も #413 でオープンになっています。

ポータブルアクターのホスティング

ポータブルアクターはEd25519鍵ペアから始まります。そのDIDは exportDidKey() を使用して公開鍵から作成され、秘密鍵を保持する者であれば誰でもそのアクターとして動作できるため、アクターと一緒に保存して安全に管理してください。

import { generateCryptoKeyPair } from "@fedify/fedify";
import { exportDidKey } from "@fedify/vocab-runtime";

const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");
const did = await exportDidKey(publicKey); // did:key:z6Mk...
// アクターの証明が参照する検証メソッド:
const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);

アクターディスパッチャーは、通常のアクターと同様にポータブルアクターを返します。Context.getPortableActorUri() およびインボックス、アウトボックス、コレクション、オブジェクト向けの関連メソッドは、ディスパッチャーのパスとDIDからポータブルIDを構築し、signObject() はDIDの鍵でアクターに署名します。

federation
 .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
 const user = await findUser(identifier);
 if (user == null) return null;
 // ゲートウェイエンドポイント経由のリクエストは、パス内でDIDを指定します
 // 例: GET /.well-known/apgateway/did:key:z6Mk.../users/alice:
 if (
 ctx.portableRequest != null &&
 ctx.portableRequest.authority !== user.did
 ) {
 return null;
 }
 // アクターに対するこのサーバーのゲートウェイ鍵(後述):
 const keys = await ctx.getActorKeyPairs(identifier);
 return await signObject(
 new Person({
 id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice
 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,
 new URL(`${user.did}#${user.did.slice("did:key:".length)}`),
 );
 })
 .setKeyPairsDispatcher(async (ctx, identifier) => {
 const user = await findUser(identifier);
 // DIDの鍵ではなく、アクターに対するこのサーバー自体の鍵ペア:
 return user == null ? [] : [user.gatewayKeyPair];
 })
 .mapPortableActorId(async (ctx, identifier) => {
 const user = await findUser(identifier);
 return user == null ? null : ctx.getPortableActorUri(identifier, user.did);
 });

このディスパッチャーにより、Fedifyは互換識別子である https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice でアクターを提供します。他のサーバーはこれを通じてアクターを取得します。ハンドルのドメインは gateways の最初のゲートウェイに由来するため、WebFinger経由では @alice@example.com として提供され、従来通り通常のURLでも提供されます。ゲートウェイエンドポイント上では、ドキュメントのIDがリクエストされたポータブルIDであり、かつそのDIDの鍵で作成された証明を含んでいる場合にのみ、Fedifyはそのドキュメントを提供します。

鍵ペアディスパッチャーおよび新しい ActorCallbackSetters.mapPortableActorId() は、アクターにゲートウェイ鍵を付与します。これらはアクターに対するサーバー自体の鍵であり、配信など、サーバーがアクターの代理として行うHTTPリクエストにのみ署名します。これらがアクターのアクティビティやオブジェクトに署名することは決してなく、それを行うのはDIDの鍵のみです。他のサーバーがHTTP Signaturesを検証できるように、FEP-521a で記述されている通り、アクタードキュメントではこれらを assertionMethods 配下にリストします。詳細については アクターのディスパッチ および ポータブルアクターのゲートウェイ鍵 を参照してください。

オブジェクトディスパッチャーとコレクションディスパッチャーも同様に、DID以降のパスによってルーティングされ、ゲートウェイエンドポイントを通じてポータブルオブジェクトおよびコレクションを提供します。2つのルールによってアクターのデータが保護されます。非公開のポータブルオブジェクトは、ディスパッチャーに認可述語(authorization predicate)がある場合にのみ提供されます。これは、FEP-ef61がゲートウェイに対してオーディエンス以外の人物へオブジェクトを開示することを禁じているためです。新しい RequestContext.isSignedByAudience() は、この述語を記述するのに役立ちます。述語がない場合、そのようなオブジェクトへのリクエストには、ゲートウェイがそれを保存していないかのように 404 Not Found が返されます。削除されたオブジェクトは、署名付きの Tombstone とともに 410 Gone として提供されます。GET /.well-known/apgateway/hl:zQm... など、ポータブルオブジェクトがSHA-256ハッシュリンクによって参照するリソースは、新しい Federatable.setHashlinkMediaDispatcher() によって提供されます。

送受信

ポータブルアクターのアクティビティは自身のDIDで署名されている必要がありますが、Fedifyの鍵ペアディスパッチャーはそのDIDを認識しません。送信前に signObject() で署名し、アクティビティがその証明の対象となる形式で送信されるよう normalizeExistingProofs: true を渡してください。Create の Note など、ポータブルオブジェクトを埋め込むアクティビティは、内側から外側へと署名します。

const actor = withGatewayHints(
 ctx.getPortableActorUri(user.identifier, user.did),
 [new URL("https://example.com")],
); // ap+ef61://did:key:z6Mk.../users/alice?@gateway=https%3A%2F%2Fexample.com
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,
 didKeyId,
);
const create = await signObject(
 new Create({
 id: ctx.getPortableObjectUri(
 Note,
 { identifier: user.identifier, id: crypto.randomUUID() },
 user.did,
 ),
 actor,
 to: PUBLIC_COLLECTION,
 object: note,
 }),
 user.didKeyPair.privateKey,
 didKeyId,
);
await ctx.sendActivity(
 { identifier: user.identifier },
 "followers",
 create,
 { normalizeExistingProofs: true },
);

この例は、FEP-ef61以外でも有用な signObject() の修正に依存しています。以前は、型付きの親オブジェクトに割り当てられた署名済みオブジェクトは、親がシリアライズされる際に親のJSON-LDコンテキストの下で再構築されていました。子オブジェクトは proofValue を保持していたものの、証明の対象となるドキュメントを失っていたため、単独での検証ができなくなっていました。現在は、signObject() が署名対象の正確なドキュメントをキャプチャし、ネストされたシリアライゼーションはそのドキュメントをそのまま埋め込みます。キャプチャされたドキュメントはスナップショットです。clone() はそれを引き継がないため、埋め込む必要がある場合はクローンに対して再度署名を行ってください。設計は #1044、修正は #1051 に記載されています。

Context.sendActivity() は、署名されていないポータブルアクティビティ、誤った鍵で署名されたポータブルアクティビティ、またはLinked Data Signatureを含むポータブルアクティビティを拒否し、キューに追加される前に TypeError をスローします。また、受信した署名済みの Follow が埋め込まれた Accept など、Fedify自身のインボックスが拒否するような特定の形式の複合ドキュメントも拒否します。そのようなオブジェクトは代わりにIDで参照してください。ポータブルインボックスへの配信は、受信者のゲートウェイ、またはインボックスの @gateway ヒントを経由して行われ、いずれかが受け入れるまで最大5つのゲートウェイを試行します。本リリースより前では、UrlError: Unsupported protocol: ap+ef61: で失敗していました。ルールは ポータブルアクターへの配信 および 複合ドキュメントの生成 に記載されています。

受信側では、ポータブルインボックスには POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox としてアクセスされ、通常のインボックスと同じインボックスリスナーへとディスパッチされます。Context.parseUri() には portable オプションが追加され、パース結果には保存済みのDIDと照合するためのDIDが新しい authority プロパティに含まれます。Fedifyは、ポータブルアクターのアクティビティ、またはポータブルIDを持つアクティビティについて、そのDIDによる有効な証明がある場合にのみ受け入れます。HTTP SignatureもLinked Data Signatureも認証には使用されません。アクティビティに埋め込まれた各ポータブルオブジェクトは、ディスパッチ前に自身の証明に対して検証されます。FEP-ef61の推奨に従い、自身の証明を保持する受け入れられたアクティビティは、アクターの他のゲートウェイへ最大1回転送されます。転送には cas() をサポートするKey-Valueストアが必要であり、FederationOptions.portableInboxForwarding オプションで調整できます。ポータブルインボックス および 他のゲートウェイへの転送 を参照してください。

ポータブルオブジェクトの使用

ポータブルアクターをホストしないアプリケーションであっても、他のサーバーからのポータブルオブジェクトを扱うことがあります。Context.lookupObject() は、ポータブルID、互換識別子、およびポータブルアクターのfediverseハンドルを解決し、検証に成功した場合にのみポータブルオブジェクトを返します。

const actor = await ctx.lookupObject("@alice@example.com");
// @gateway ヒントのないポータブルIDには、問い合わせ先のゲートウェイが必要です:
const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", {
 gateways: ["https://example.com"],
});

プロパティアクセサーも同様にゲートウェイ経由でポータブル参照を取得し、新しい verifyPortableObject オプションによって取得した内容を検証します。Context には対応する verifyPortableObject プロパティがあるため、await create.getObject(ctx) のようにコンテキストをオプションとして渡すだけで十分です。インボックスで受信したアクティビティはデフォルトでこの検証機能を使用し、そのアクセサー経由で取得されたオブジェクトもそれを継承するため、(await create.getObject(ctx))?.getAttribution() のようなチェーン呼び出しでは、処理の過程にある各ポータブルオブジェクトが検証されます。ルックアップは最大5つのゲートウェイに問い合わせを行い、いずれかが失敗するか検証できないものを返した場合は次のゲートウェイへとフォールバックします。署名されていないコレクションは、FEP-ef61で認められている通り、所有者がリストしているゲートウェイから提供され、かつアクターの inbox、outbox、followers、following、および liked である場合にのみ受け入れられます。fetchPortableMedia() はポータブルオブジェクトのメディアを取得し、オブジェクトの digestMultibase と一致する場合にのみそれを返します。ポータブル参照の解決 および デフォルトの検証機能 を参照してください。

互換識別子はオリジンによって信頼されなくなりました

ap: URIを解釈できないソフトウェアは、https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor のように、ゲートウェイの /.well-known/apgateway/ パス配下にあるHTTP(S) URLである互換識別子によってポータブルオブジェクトを参照できます。tootik を含む一部の実装は、ポータブルアクターのIDとして互換識別子を使用しています。

Fedifyの以前のバージョンでは、これらのURLを通常のWeb URLとして扱い、それを提供したサーバーに基づいて信頼していました。任意のDIDに対する互換識別子は誰でも提供できるため、https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor がAliceのDIDによる証明なしにAliceとして振る舞うことが可能でした。Fedify 2.4.0では、FEP-ef61の要件に従い、互換識別子をそれが表すポータブルオブジェクトとして扱います。インボックスは、IDが互換識別子であるアクターからのアクティビティに対し、そのアクターのDIDによる有効なObject Integrity Proofがない限り、401 Unauthorized で拒否します。これを無効にするオプションはありません。署名を行わずに互換識別子を公開しているアクターや、Fedifyがまだ検証できない鍵タイプを使用しているDIDを持つアクターは、受け入れられなくなりました。この変更は #1093 で追跡され、#1105 で実装されました。

ap: IDを拒否するソフトウェア向けに、独自にホストするポータブルアクターのIDとして引き続き互換識別子を使用することも可能です。署名前に toCompatibleEf61Id() を使用して構築すれば、Fedifyは配信時や送信時のあらゆる場所でそれらをポータブルなものとして扱います。また、形式が不正である場合や、アクターの最初のゲートウェイを指していない場合には警告を出力します。アクターIDとしての互換識別子 を参照してください。

FedifyのプロファイルがFEPと異なる点

Fedifyのプロファイルにおける2つの選択は、現在のFEPの本文と異なっているか、あるいはまだ確定していないセマンティクスに基づいています。Fedifyは2.xシリーズにおいて両方を維持しますが、Fedify 3.0でいずれかが変更される可能性があります。

1つ目はスキームです。FEP-ef61は ap: を推奨し、ap+ef61: をそれに正規化する一方で、これらのIDがポータブルオブジェクトのみを対象としていることから、推奨が ap+ef61: に変更される可能性があることも警告しています。Fedifyは両方を受け入れ、等価として比較し、ap+ef61: としてシリアライズします。ドキュメントをパースするとそのポータブルIRIは ap+ef61: に正規化されるため、他の実装が ap: IDで署名したオブジェクトを再シリアライズすると、その証明が破損します。そのようなドキュメントについては受信したJSONを保持し、ポータブルIDの比較は文字列としてではなく arePortableUrisEqual() を使用して行ってください。議論は #826 および #828 に、根拠は 正規の ap+ef61: スキーム に記載されています。

2つ目は複合ドキュメントに関するものです。署名された Note が署名された Create の内部に存在する場合、検証機能はJSONのどの部分を各証明がカバーしているかを認識していなければなりませんが、FEP-8b32も Verifiable Credential Data Integrity も現時点ではそれを定義していません。この問題は w3c/vc-data-integrity#350 でオープンになっています。Fedifyは、#938 で策定された暫定的な マップローカル(map-local) プロファイルを使用します。proof を持つ各JSONマップは独立した保護対象ドキュメントであり、埋め込まれた各ポータブルオブジェクトには独自の証明と独自の @context が必要です。また、証明セット(proof sets)や証明チェーン(proof chains)はサポートされません。このプロファイルは、各証明がカバーする正確なJSON値を認証します。親オブジェクト内における子オブジェクトのJSON-LD展開結果が、単独での展開結果と一致することは保証しません。そのスレッドにおいて、@silverpill、@dmitrizagidulin、および @by_caballero が提案をレビューし、@silverpill がテストベクターを個別に再現しました。マップローカル複合証明 を参照してください。

実装されていない機能

Fedifyは、クライアントサイド署名に関する関連提案である FEP-ae97 を実装していません。アクターの登録、クライアント側で署名されたアクティビティのポータブルアウトボックスへの送信、あるいはゲートウェイディスカバリーのためのエンドポイントはなく、/.well-known/apgateway-media 配下のメディアアップロードおよび削除エンドポイントも存在しません。独自のアウトボックスリスナーにおいて、クライアントが署名したアクティビティを変更せずにそのままリレーすることは可能です。また、Fedifyはゲートウェイ間におけるアクター、オブジェクト、またはコレクションのコピーや調整(reconciliation)を行わないため、複数のゲートウェイでアクターを運用する場合は、自身でデータを同期させておく必要があります。DIDの鍵のローテーションや、別DIDへのアクターの移行を行うワークフローはありません。Fedifyが解決する検証メソッドは、Ed25519鍵を持つ did:key DIDのみです。その他のDIDメソッドは、独自のドキュメントローダーがそれらを解決できる場合にのみ検証されます。FEP-ef61で言及されている任意のゲートウェイパスはサポートされていないため、ゲートウェイはプレーンなオリジンでなければなりません。完全なリストは サポートされていない機能 に記載されています。

相互運用性

このプロファイルは、#1202 においてtootik v0.25.4およびMitra v5.10.0に対してテストされました。Fedifyは、tootikのポータブルアクター、アクティビティ、およびHTTP Signaturesを検証し、Mitraについてはクライアントが署名したアクティビティに対するゲートウェイのHTTP Signaturesを検証します。両者とも、Fedifyが送信するポータブルアクティビティを受け入れます。このテストを通じて2つの修正が行われました。Fedifyは、tootikがJSON-LDコンテキストで未マッピングのままにしている gateways リストを読み取るようになりました。また、Mitraが末尾のスラッシュを拒否するため、ゲートウェイをプレーンなオリジンとしてシリアライズするよう変更されました。Mitra自体が署名したアクターやアクティビティについてはFedifyでまだ検証されておらず、Mastodonによるポータブルアクターの処理もテストされていません。何がどのようにテストされたかは、FEDERATION.md の FEP-ef61の相互運用性 セクションに記録されています。

ツーリング

fedify lookup は、ポータブルオブジェクトをその ap: および ap+ef61: ID、互換識別子、ならびにポータブルアクターのハンドルによってルックアップし、それらの証明を検証します。受け入れ可能なオブジェクトを返すゲートウェイが存在しない場合、その理由を表示します。fedify lookup、fedify inbox、および fedify webfinger に新設された --gateway オプションにより、問い合わせ先のゲートウェイを指定できます。fedify inbox --follow はポータブルアクターのフォローに対応し、fedify webfinger はポータブルアクターIDを受け付けます。CLIマニュアルの ポータブルオブジェクトのルックアップ を参照してください。

@fedify/testing のモックフェデレーションおよびコンテキストは、ポータブルID、フィクスチャドキュメントローダーに対する検証、ゲートウェイリクエスト、およびハッシュリンクメディアをサポートしているため、稼働中のゲートウェイがなくてもテストでポータブルオブジェクトを動作させることが可能です。@fedify/lint のアクターURIルールは、getPortable*Uri() メソッドが構築するポータブルIDを受け入れます。@fedify/next はハッシュリンクメディアリクエストをFedifyに渡すようになりました(この問題は #1149 で報告され、#1170 で修正されました)。これらに対してNext.jsでミドルウェアを実行させるには、matcher に { source: "/.well-known/apgateway/:path*" } を追加してください。

バックグラウンドタスク

Fedifyはすでにキューワーカー上でアクティビティ配信とインボックス処理を実行していますが、通常、アプリケーションにはダイジェストメールの送信、タイムラインの再構築、リンクプレビューなど、独自のジョブが存在します。これまで、これらのジョブではFedifyのキュー、シリアライゼーション、リトライ機能を利用できませんでした。Fedify 2.4.0では、同じメカニズムをアプリケーション定義のタスクにも開放します。

タスクは defineTask() で一度定義すれば、任意の Context から enqueueTask() または enqueueTaskMany() を使用してエンキューできます。すべてのタスクは、ペイロードの型を定義する Zod や Valibot スキーマなどの Standard Schema を受け取ります。Fedifyはエンキュー時と、ワーカーがタスクを取得した時の両方でペイロードを検証します。2回目のチェックにより、以前のデプロイによって生じたスキーマに一致しなくなったペイロードを検出できます。

import { createExponentialBackoffPolicy } from "@fedify/fedify";
import { z } from "zod";

const sendDigest = federation.defineTask("sendDigest", {
 schema: z.object({ userId: z.string(), since: z.date() }),
 handler: async (ctx, data) => {
 // dataは { userId: string; since: Date } として型付けされます
 await sendEmail(data.userId, await buildDigest(data.userId, data.since));
 },
 retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),
});

// Contextが存在する任意の場所で:
await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, {
 delay: { minutes: 30 },
 deduplicationKey: "digest:alice",
});

Fedifyは devalue を使用してペイロードをシリアライズするため、Date、Map、Set、URL、bigint、Temporal の値、循環参照、およびActivity Vocabularyオブジェクトはすべて、メッセージキューのバックエンドを経由しても損なわれません。ペイロード内の Note は Note として復元されます。失敗したハンドラーはデフォルトで指数バックオフによってリトライされ、ネイティブのリトライ機能を備えたキューは自身でリトライを処理します。タスク専用のキューを指定しない限り、タスクはアウトボックスキューへとフォールバックします。専用キューを割り当てるには、queue オプションの新しい task スロットまたはタスクごとの queue を使用し、startQueue(ctxData, { queue: "task" }) を実行することでタスク専用ワーカーを起動できます。taskQueueResolution: "strict" を設定すると、フォールバックが発生した場合にエラーになります。

deduplicationKey はエンキューの重複を排除します。この機能は、#206 において @julian が指摘した、すでにキューに入っているかどうかを判別できないと定期的なタスクの管理が困難になるという問題に端を発しています。nativeDeduplication を宣言しているキューはキーの重複排除を自身で適用します。そうでない場合、Fedifyは cas() を介してKey-Valueストア内にベストエフォート型のマーカーを保持します。各タスクは fedify.task スパンで実行され、2.3.0からのキューメトリクスにタスクキューも含まれるようになりました。バックグラウンドタスク の章では、ルーティング、リトライ、重複排除、および制限事項について説明しています。なお、cron形式のスケジューリング、結果バックエンド、優先度は現時点では対象外です。

このAPIはChanHaeng Lee (@2chanhaeng) によって実装されました。#797、#798、#799 の計画に従い、コア機能が #803、重複排除が #806、オブザーバビリティが #812 で実装されています。この取り組みは #923 でマージされ、2025年2月からオープンになっていた #206 がクローズされました。

メディアアップロード

Fedify 2.2.0でクライアント・ツー・サーバー(C2S)のアウトボックスリスナーが追加されましたが、C2Sクライアントは画像を投稿する前に画像や動画をアップロードする必要もあります。そのトラフィックは POST /outbox を経由しません。Fedify 2.4.0は、2017年に最終更新され、W3C勧告(Recommendation)には至らなかったSocialCGの提案である ActivityPub Media Upload extension をサポートします。

setMediaUploader() は multipart/form-data エンドポイントを登録します。そのコールバックは、アップロードされた file と、クライアントがそれに付随して送信した object のシェルを受け取ります。コールバックが作成されたオブジェクトを返した場合、Fedifyは 201 Created で応答します。処理完了後にオブジェクトが配置される URL を返した場合、Fedifyは 202 Accepted で応答します。

federation
 .setMediaUploader(
 "/users/{identifier}/media",
 async (ctx, identifier, file, object) => {
 const stored = await uploadToStorage(file);
 return new Image({
 id: ctx.getObjectUri(Image, { uuid: stored.uuid }),
 url: new URL(stored.publicUrl),
 mediaType: file.type,
 name: object.name,
 });
 },
 )
 .authorize(async (ctx, identifier) => {
 const session = await verifyAccessToken(
 ctx.request.headers.get("authorization"),
 );
 return session?.identifier === identifier;
 });

アクターディスパッチャーは、インボックスやアウトボックスのURIを配置するのと同様に、endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) }) を設定してエンドポイント自体をアドバタイズします。authorize() フックがない場合、エンドポイントを見つけた誰もがそこにアップロードできてしまいます。そのためFedifyは、アップローダーが認可フックなしで登録された場合や、返されたオブジェクトのURIがオブジェクトディスパッチャーと一致しない場合、あるいはアクターがエンドポイントをアドバタイズしていない場合に警告ログを出力します。4つの新しい @fedify/lint ルールにより、実行前に同様のミスを検出できます。Fedifyはアップロードされたデータをメモリに読み込み、独自のリクエストサイズ制限を設けていないため、リバースプロキシ側でリクエストサイズの上限を設定してください。ファイルの保存およびオブジェクトの返却配信はアプリケーション側の責務となります。メディアアップロード の章を参照してください。実装作業は #754 および #927 で行われました。

インボックスリクエストレポート

Fedifyは、すべてのインボックス配信に関して、どの署名メカニズムが適用されたか、どの鍵が試行されたか、アクターがその鍵を所有していたか、そしてアクティビティがどう処理されたかなど、いくつかの判断を行います。本リリース以前は、アプリケーション側にはその一部しか開示されていませんでした。アクターが所有していない鍵によるLinked Data Signatureは不正な署名と同様に扱われ、リクエストを検証した鍵がアプリケーションに渡されることはありませんでした。#1191 におけるリクエストは、デバッグツールのためにこれらの判断をログ記録しようとする取り組みから生じたものです。

インボックスリスナーに新設された onRequestFinished() コールバックは、拒否されたリクエストや検証前に失敗したリクエストを含め、インボックスルートへのあらゆる配信に対して InboxRequestReport を受け取ります。

federation
 .setInboxListeners("/users/{identifier}/inbox", "/inbox")
 .on(Follow, async (ctx, follow) => { /* ... */ })
 .onRequestFinished(async (ctx, report) => {
 await db.insertInboxLog({
 inbox: report.inbox.kind, // "personal" | "shared" | "portable"
 authentication: report.authentication.status,
 outcome: report.outcome.type === "response"
 ? report.outcome.disposition // "processed" | "enqueued" | "duplicate" | ...
 : `exception:${report.outcome.stage}`,
 keyIds: report.attempts
 .flatMap((attempt) => attempt.checks)
 .flatMap((check) => check.triedKeys)
 .map((key) => key.id?.href),
 });
 });

個々のHTTP Signature、Linked Data Signature、またはObject Integrity Proofのチェックは、期限切れのキャッシュされた鍵やそのリフレッシュ後の代替鍵を含め、実際に試行された鍵を伴う試行(attempt)として記録されます。また、レポートには actorKeyMismatch や proofPolicy などの最終的な認証判定や、リクエストを終了させたレスポンスまたは例外も含まれます。トレースがサンプリングされているかどうかにかかわらず、Fedifyはこのコールバックを待機します。コールバック内でスローされたエラーはログに記録されますが、レスポンス自体は変更されません。また、enqueued はキューがアクティビティを受け入れたことを意味し、ワーカーが後からそれを処理したことを示すものではありません。@fedify/testing のモックフェデレーションも同一のコールバックを受け付けます。インボックスリクエストのオブザーブ を参照してください。実装は #1201 にあります。

削除されたオブジェクトに対するトゥームストーン

2.2.0以降、アクターディスパッチャーは Tombstone を返すことが可能でしたが、オブジェクトディスパッチャーは null しか返せませんでした。削除された投稿には最初から存在しなかったかのように 404 Not Found が応答されていたため、ActivityPubの推奨に従って 410 Gone を返したいアプリケーションは、Fedifyの手前に独自のルートを設ける必要がありました。

オブジェクトディスパッチャーが Tombstone を返せるようになり、Fedifyは認可述語(authorization predicate)を適用した上で、シリアライズされたトゥームストーンとともに 410 Gone を提供します。

federation.setObjectDispatcher(
 Note,
 "/users/{identifier}/notes/{id}",
 async (ctx, values) => {
 const post = await getPost(values.identifier, values.id);
 if (post == null) return null;
 if (post.deletedAt != null) {
 return new Tombstone({
 id: ctx.getObjectUri(Note, values),
 formerType: Note,
 deleted: post.deletedAt,
 });
 }
 return new Note({ id: ctx.getObjectUri(Note, values), content: post.content });
 },
);

ObjectDispatcher の戻り値の型は TObject | Tombstone | null に拡張されます。RequestContext.getObject() は、{ tombstone: "passthrough" } を渡すか、Tombstone または Object クラス自体を要求しない限り、トゥームストーンに対して引き続き null を返します。すでにトゥームストーンを返していた Tombstone または Object 用に登録されたディスパッチャーは、200 ではなく 410 で応答するようになります。削除されたオブジェクト を参照してください。この変更は #1112 で提案され、#1117 で実装されました。

リモートピアがFedifyに及ぼし得る影響の制限

Fedifyが取得するデータの多くは、受信リクエストの送信元によって指定されます。署名内の鍵IDはその一例であり、それらの背後にあるサーバーは意図的に応答を遅延させることが可能です。2.4.0におけるいくつかの変更により、それらの処理に上限が設けられました。

#1130 で指摘されているように、リクエストには複数のRFC 9421署名が含まれる場合があり、送信元が選んだURLからFedifyに鍵を取得させることができます。Fedifyは、Signature-Input の順序で最大3件までの署名を検証し、残りを無視するようになりました。鍵が取得される前に失敗した場合でも署名は上限数としてカウントされ、複数の署名から指定された鍵のルックアップは一度だけ行われます。#1166 でこの制限とともに追加された新しい FederationOptions.maxHttpSignatures オプションにより、この設定を変更できます。

組み込みのドキュメントローダーは、各呼び出しに対してデフォルトで10秒の制限時間を設けるようになりました。この制限は #1131 で提案され、#1169 で追加されたものです。この時間制限には、すべてのリダイレクト、代替ドキュメント、ダブルノッキングのリトライ、およびレスポンスボディが含まれます。タイムアウトした呼び出しは、"TimeoutError" という名称の DOMException を cause とする FetchError をスローします。エラーレスポンスは、ローダーがスローする前に最大1 MiBまで完全に読み取られるため、サーバーが 404 ヘッダーを送信した後にボディの送信を遅延させて滞らせることはできません。FederationOptions.documentLoaderTimeout によって制限を変更するか、null で無効化できます。カスタムドキュメントローダーはこの影響を受けません。また、#913 以降、getDocumentLoader() はActivityPubの代替リンクをアドバタイズしないHTMLレスポンスに対し、JSON.parse() の内部で失敗するのではなく FetchError で拒否するようになりました(これは #912 においてエラー追跡ツールを煩雑にしていることが判明した問題です)。

キャッシュされた公開鍵と記憶されたHTTP Message Signaturesの仕様は、かつてKey-Valueストア内に永続的に保持されていました。#1017 では、本番環境のRedisインスタンスにおいて、大半がフェデレーションを停止したアクター向けに194,424件(約165 MiB)もの公開鍵エントリが保持されていた事例が報告されています。鍵は30日後、仕様は90日後に期限切れとなるよう変更され、FederationOptions.publicKeyTtl および FederationOptions.httpMessageSignaturesSpecTtl で設定可能です。以前のバージョンによって書き込まれたエントリには、次回書き込まれるまで有効期限が設定されません。レガシーキャッシュエントリの削除 セクションでは、Redis、PostgreSQL、およびその他のストアにおいてこれらを削除する方法を解説しています。これはHeewon Chae (@heeoneie) によるFedifyへの最初の貢献として、#1027 で実装されました。

const federation = createFederation<void>({
 kv,
 maxHttpSignatures: 1, // デフォルト: 3
 documentLoaderTimeout: { seconds: 5 }, // デフォルト: 10秒
 publicKeyTtl: { days: 7 }, // デフォルト: 30日
 httpMessageSignaturesSpecTtl: { days: 30 }, // デフォルト: 90日
});

最後に、verifyProof() は、expires、domain、challenge、nonce を含む、proofValue を除くEd25519 JCS証明のすべてのオプションを認証するようになりました。また、期限切れまたは形式が不正な証明も拒否します。以前は、署名済みの証明に対して expires などのフィールドを追加しても、証明が無効化されませんでした。Jiwon Kwon (@z9mb1) が #968 のレビュー中にこの問題を発見しました。クロスドメインでの使用やリプレイ攻撃を防ぐため、呼び出し側は VerifyProofOptions を通じて domain と challenge を必須に指定できるようになりました。

新しいパッケージ

@fedify/adonisjs

@fedify/adonisjs はFedifyを AdonisJS と統合します。node ace add @fedify/adonisjs を実行すると、フェデレーションのライフサイクルを管理するサービスプロバイダーと、それをマウントするサーバーミドルウェアが登録されます。また、config/fedify.ts、start/federation.ts、および app/federation/main.ts のスキャフォールディングも行われます。コントローラーは ctx.federation リクエストコンテキストを受け取ります。

// app/federation/main.ts
import federation from "@fedify/adonisjs/services/builder";
import { Person } from "@fedify/vocab";

federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) =>
 new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));

このミドルウェアは、AdonisJSのルータースタックではなくサーバースタックの最上位に配置されます。これにより、Fedifyは /.well-known/webfinger などルーターが認識しないパスを処理でき、ボディパーサーが消費する前にリクエストボディを読み取ることが可能になります。このパッケージはNode.jsを対象としており、npmにのみ公開されています。統合ガイドの AdonisJSのセクション を参照してください。

Samuel Brinkmann (@sabrinkmann) が #1006 でこのパッケージをコントリビュートして #139 をクローズし、そのイシューでEmelia Smith (@thisismissem) が添付したプロトタイプをベースに構築されました。

npm add @fedify/adonisjs
pnpm add @fedify/adonisjs
yarn add @fedify/adonisjs

@fedify/interaction-controls

GoToSocial interaction controls、FEP-044f 引用認可、および FEP-7aa9 おすすめコレクション(featured collections)は、共通のリクエスト・認可フローを持っています。サーバーが投稿への返信、引用、またはおすすめ(feature)を要求し、投稿者のサーバーがその投稿の interactionPolicy に従って承認します。@fedify/interaction-controls は、いいね(like)、返信(reply)、アナウンス(announce)、引用(quote)、およびおすすめ(feature)のインタラクションに対してこのフローを実装します。各インタラクションには likeInteraction や quoteInteraction などのヘルパーがあり、リクエストと認可の作成および検証、ポリシーの評価、ならびにリクエストなしで届いたインタラクションの認識を行います。

import { likeInteraction } from "@fedify/interaction-controls";

// LikeRequestが届いた場合:
const verified = await likeInteraction.verifyRequest(ctx, { request });
if (verified.verified) {
 const decision = await likeInteraction.evaluatePolicy(ctx, {
 subject: note,
 requester: request.actorId!,
 });
 if (decision.result === "automatic") { // または "manual"、"denied"
 const authorization = likeInteraction.createAuthorization({
 id: new URL("https://example.com/authorizations/1"),
 attributedTo: owner,
 interactingObject: like,
 interactionTarget: note,
 });
 // ...
 }
}

このパッケージはインボックスリスナーをインストールせず、データの保存も行いません。リクエストと認可をどこに保持するか、および人間の承認が必要なタイミングはアプリケーション側で決定します。検証はフェイルクローズ(安全側に倒す設計)となっています。ポリシーがない場合、いいね、返信、およびアナウンスは自動的に承認されますが、引用とおすすめは拒否されます。@fedify/vocab には、これに必要なFEP-7aa9の型として FeaturedCollection、FeaturedItem、FeatureRequest、FeatureAuthorization が追加され、アクターの featuredCollections および InteractionPolicy.canFeature も加わりました。Mastodon 4.6がこのFEPをリリースしたことを受け、Samuel Brinkmannが #810 でこれらの対応を求めました。インタラクションコントロール の章を参照してください。実装作業は #914 および #929 で行われました。

npm add @fedify/interaction-controls
pnpm add @fedify/interaction-controls
yarn add @fedify/interaction-controls
deno add jsr:@fedify/interaction-controls
bun add @fedify/interaction-controls

@fedify/netlify

@fedify/netlify を使用すると、外部データベースやキューを用意することなく、FedifyアプリケーションをNetlify Functions上で実行できます。NetlifyMessageQueue は Netlify Async Workloads イベントとしてジョブを送信し、createNetlifyQueueHandler() はFunctionsをそれらのコンシューマーへと変換して、遅延配信、ネイティブのリトライ、およびキーごとのFIFO順序付けを提供します。NetlifyBlobsKvStore は、有効期限、プレフィックス一覧取得、およびCompare-and-Swapを備えたNetlify Blobs上のKey-Valueストアです。

import {
 createNetlifyQueueHandler,
 NetlifyBlobsKvStore,
 NetlifyMessageQueue,
} from "@fedify/netlify";
import { AsyncWorkloadsClient } from "@netlify/async-workloads";
import { getStore } from "@netlify/blobs";

export const kv = new NetlifyBlobsKvStore(
 getStore({ name: "fedify", consistency: "strong" }),
);
export const queue = new NetlifyMessageQueue({
 client: new AsyncWorkloadsClient(),
 orderingKv: kv,
});

// netlify/functions/fedify-queue.ts
export default createNetlifyQueueHandler({
 queue,
 maxRetries: 4,
 federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),
});

このKey-Valueストアはコスト面への配慮から作成されました。#1010 では、静的ブログのActivityPubトラフィックによってNetlify Databaseがアイドル状態にならず、アカウントの月間クレジットの大半が消費されてしまった事例が報告されています。Jiwon Kwonが #1029 で NetlifyBlobsKvStore をコントリビュートし、キュー機能は #934 で取り込まれました。デプロイガイドの Netlify Functions を参照してください。

npm add @fedify/netlify
pnpm add @fedify/netlify
yarn add @fedify/netlify
deno add jsr:@fedify/netlify
bun add @fedify/netlify

@fedify/pglite

@fedify/pglite は、独自に作成して渡す組み込みの PGlite データベース上で動作する KvStore である PgliteKvStore を提供します。これは PostgresKvStore と同一のスキーマを使用します。単一のランタイムアイソレート内の単一PGliteインスタンスを想定しているため、メッセージキューは含まれません。PGliteはプロセス間でデータを共有しないためです。

import { PGlite } from "@electric-sql/pglite";
import { PgliteKvStore } from "@fedify/pglite";

const federation = createFederation({
 kv: new PgliteKvStore(new PGlite("./data/fedify")),
});

ChanHaeng Leeが @fedify/testing の testKvStore() と併せて、#1020 でこのパッケージをコントリビュートしました。testKvStore() は KvStore 実装向けの適合性スイート(conformance suite)であり、testMessageQueue() に対応するものです。PgliteKvStore を参照してください。

npm add @fedify/pglite
pnpm add @fedify/pglite
yarn add @fedify/pglite
deno add jsr:@fedify/pglite
bun add @fedify/pglite

PostgreSQLおよびRedis向けのCompare-and-Swap

ポータブルインボックスの転送、タスクの重複排除、Netlifyキューの順序付け、および配信サーキットブレーカーは、すべてKey-ValueストアでのアトミックなCompare-and-Swapを必要とします。#934 で追加された PostgresKvStore が cas() を実装するようになり、#1163 でリクエストされ #1167 で追加された RedisKvStore も同様に実装しました。Redis版はシングルキーのLuaスクリプトを使用しており、スタンドアロンのRedisおよびRedis Clusterで動作します。これ以前は、Redis上でのサーキットブレーカーは競合の起こりやすい更新へとフォールバックしていました。

どちらの実装にも条件があります。RedisKvStore はRedisで EVAL が許可されている必要があり、カスタムコーデックは等価な値を同一にエンコードしなければなりません。PostgresKvStore は、クラッシュリカバリ時にシーケンスの状態が失われるとキューイングされたイベントが恒久的にブロックされる恐れがあるため、デフォルトでLOGGEDテーブルを作成するようになりました。初期化時には、既存のUNLOGGEDテーブルのマイグレーションが行われます。この一度限りのマイグレーションはテーブルを書き換え、排他ロックを獲得するため、テーブルのサイズが大きい場合やビジー状態の場合は、それに応じたアップグレード計画を立ててください。unlogged: true を指定すると以前の動作が維持されます。

fedify init とCLI

fedify init で SvelteKit プロジェクトのスキャフォールディングが可能になりました。これは #971 においてJang Hanarae (@menele) によってコントリビュートされました。また、Jang Hanaraeは #990 で生成されたプロジェクトに test タスクを追加しました。このタスクはアプリを起動して準備完了を待ち、ローカルアクターが解決されるかをチェックするため、新規プロジェクトのスキャフォールディング直後やその後の変更時に実行できるスモークテストとして機能します。何も生成する前の段階で、fedify init は選択されたDeno、Bun、またはNode.jsがFedifyの最低バージョン、あるいはAstroのNode.js 22.12といったフレームワーク側のより高い要件を満たしているかをチェックするようになりました。非対話モードでは明確なエラーを出して失敗し、対話モードでは影響を受けるパッケージマネージャーを無効化します。Lee Jeongmin (@userjmmm) が #981 でこのチェックをコントリビュートしました。AstroプロジェクトはAstro 7を使用するようになり、#936 以降、#931 の要求に従って @fedify/astro はNode.js、Deno、Bun上での実際のビルドを用いてAstro 5、6、7に対してテストされています。

fedify tunnel には、Fedifyプロジェクトが運営するアカウント不要のトンネリングサービスである fedify.com.es が追加されました。CLIはそのSSHホスト鍵をピン留めし、ローカルポートを公開する前に不一致のサーバーを拒否します。同じ変更である #940 において、サービスが終了したため localhost.run は削除されました。fedify lookup は、-T/--timeout が指定されていない場合に各リクエストを10秒後にタイムアウトさせるようになり、このオプションはDNSルックアップの待機時間も制限するようになりました。CLIは #1197 でOptique 1.3.2に移行し、--timeout=30 のような付属する値を一貫して処理できるようになり、誤入力されたオプションに対してより適切な候補を提示し、-- の前にある位置引数の背後に既知のオプションのエラーを隠さなくなりました。

Lintルール

@fedify/lint には、前述のメディアアップロードに関するもの以外にもいくつかのルールが追加されました。#895 でリクエストされ #1022 で追加された actor-preferred-username-required は、アクターディスパッチャーが preferredUsername のないアクターを返した際に警告を出します。#1057 および #1067 による outbox-listener-delivery-not-awaited は、アウトボックスリスナーが ctx.sendActivity() や ctx.forwardActivity() を呼び出しながらそのPromiseを放置している場合に報告します。Cloudflare Workersでは、レスポンスが返されると保留中の処理が破棄されるため、そのようなアクティビティは送信されない可能性があります。await、return、Promise.all()、Promise.allSettled()、および waitUntil() はPromiseを処理したものとみなされ、void、Promise.race()、Promise.any() は待機しないという意図的な選択として受け入れられます。このルールはESLintの recommended 設定では警告、strict ではエラーとなります。Oxlintユーザーは名前を指定して有効化でき、Deno Lintでは利用できません。

#900 を解決する #1050 において、既存の outbox-listener-delivery-required ルールはリスナーのソースコードをテキストとして走査するのではなく、制御フローを追跡するようになりました。唯一の配信呼び出しがデッドブランチ、無条件の return や throw の後、または決して使用されない関数内に存在する場合にリスナーを報告し、判別できない場合は何も報告しません。Jae-Hyuk-Jang (@jhyuk) がこの変更と両方の新しいルールをコントリビュートしました。その後、archie (@ArchieTansaria) が #1088 で静的に偽となるループやループバインディングパターンを両方の配信ルールに認識させました。Lintガイド を参照してください。

その他の変更点

  • @fedify/vocab は翻訳に関する FEP-22cd ドラフトをサポートします。Translation は翻訳版の言語、翻訳者、ソースオブジェクト、およびソースの確認日時を記録し、新しい Object.translations プロパティは別個の投稿を作成することなくそれらを投稿に関連付けます。翻訳メタデータ を参照してください。(#1037、#1038)
  • @fedify/vocab-tools には、その用語が使用されている場合にのみJSON-LDコンテキストを追加する extraContext プロパティオプションと、trustEmbeddedObjects 型オプションが追加されました。(#1037、#1038)
  • suppressError: true によって処理されるVocabularyの取得およびパースの失敗は、エラーではなく警告としてログ記録されるようになり、アプリケーションエラーとして表示されなくなりました。Jae Hui Hong (@axz0612) によってコントリビュートされました。(#933、#1035)
  • enqueueMany() が個別の送信を行うキューは、MessageQueue.atomicEnqueueMany を false に設定できます。この場合、部分的な送信に続くリトライによって重複エンキューが発生する恐れがあるため、Fedifyは単一の deduplicationKey で管理される複数メッセージのバッチを事前に拒否します。(#930、#934)
  • Temporal を使用するCommonJSビルドは、ランタイムで @js-temporal/polyfill を要求する代わりに temporal-polyfill をバンドルし、型宣言は標準の esnext.temporal ライブラリに依存します。(#823、#925)
  • ActivityPubおよびFEP-ef61ゲートウェイの要件に従い、ドキュメントローダーの Accept ヘッダーのJSON-LDメディアタイプにActivityStreamsプロファイルが含まれるようになりました。(#834、#1077)
  • リレーのドキュメントにおいて、正規のアクターURIおよび共有インボックスURIを使用し、Mastodon形式とLitePub形式のサブスクリプションを分離し、アプリケーション側の責務として残る事項についての説明が追加されました。Jiwon Kwonによってコントリビュートされました。(#899、#996)

バグ修正

  • エンコードされた値を比較するKey-Valueストアにおける、アウトバウンド配信サーキットブレーカーの遷移を修正しました。既存のハーフオープン状態は、CAS対応ストアに切り替えることで回復できます。(#1163、#1167)
  • fedify lookup --recurse がタイムアウトやその他のネットワーク障害を非公開オブジェクトの可能性があるものとして報告していた問題を修正しました。実際に対象となった原因を報告するようになりました。(#1156、#1199)
  • fedify webfinger の -p/--allow-private-address オプションが無視されていた問題を修正しました。(#1156、#1199)
  • ネストされたヘルパーがオブジェクトの別のプロパティに代入を行った際、outbox-listener-delivery-required および outbox-listener-delivery-not-awaited がそのオブジェクトの関数を見失ってしまう問題を修正しました。(#1125、#1140)

アップグレード

npm add @fedify/fedify@2.4.0
pnpm add @fedify/fedify@2.4.0
yarn add @fedify/fedify@2.4.0
deno add jsr:@fedify/fedify@2.4.0
bun add @fedify/fedify@2.4.0

使用している他の @fedify/* パッケージも同時に2.4.0へアップデートしてください。CLIをアップデートするには npm install -g @fedify/cli@2.4.0 を実行するか、その他のパッケージマネージャーについては インストール手順 を参照してください。

ほとんどのアプリケーションはコードの変更を必要としませんが、以下の動作の変更による影響を受ける可能性があります。

  • IDがFEP-ef61互換識別子であるアクターからのアクティビティは、そのアクターのDIDによる有効なObject Integrity Proofを含んでいない限り、401 Unauthorized で拒否されます。互換識別子にある鍵、およびポータブルな所有者を主張する通常のURLにある鍵は、オリジンによって信頼されなくなりました。
  • await create.getObject(ctx) のようにオプションとして Context が渡されたプロパティアクセサーは、通常のHTTP(S) IDを持つオブジェクトからの互換識別子への参照を含め、解決(dereferencing)するポータブルオブジェクトを検証するようになりました。
  • ディスパッチャーが /.well-known/apgateway/ へのリクエストに対して呼び出される可能性があり、Context.sendActivity() は他者のポータブルオブジェクトを埋め込んだアクティビティを拒否する場合があります。Next.jsアプリケーションでハッシュリンクメディアを提供するには、前述のマッチャー(matcher)が必要になります。
  • リクエストのRFC 9421署名は、最初の3件のみが検証されます。それ以上必要な場合は maxHttpSignatures を設定してください。
  • 組み込みのドキュメントローダーは10秒後にタイムアウトするようになり、fedify lookup も同様にタイムアウトします。
  • キャッシュされた公開鍵は30日後、HTTP Message Signaturesの仕様は90日後に期限切れとなります。
  • トゥームストーンを返す Tombstone または Object 用に登録されたオブジェクトディスパッチャーは、200 OK ではなく 410 Gone で応答するようになります。
  • PostgresKvStore は初期化時に既存のUNLOGGEDテーブルをLOGGEDテーブルへマイグレーションし、その際にテーブルをロックします。
  • RedisKvStore は cas() のために EVAL の権限を必要とします。
  • 以前のバージョンのキューワーカーは、ポータブルインボックス向けのキューイングされたアクティビティを最初のゲートウェイ経由でのみ配信します。配信をエンキューするサーバーよりも先にワーカーをアップグレードしてください。
  • Context および RequestContext インターフェースのカスタム実装には、新しい getPortable*Uri() メソッドおよび isSignedByAudience() が必要です。
  • fedify tunnel は localhost.run をサポートしなくなりました。

謝辞

本リリースのコントリビューターに感謝いたします。

  • Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs、およびFEP-7aa9ボキャブラリのリクエスト
  • Jang Hanarae (@menele): fedify init におけるSvelteKitサポートおよび test タスク
  • Lee Jeongmin (@userjmmm): fedify init におけるランタイムバージョンの検証
  • Jae-Hyuk-Jang (@jhyuk): actor-preferred-username-required および outbox-listener-delivery-not-awaited Lintルール、ならびに outbox-listener-delivery-required 向けの制御フロー解析
  • archie (@ArchieTansaria): アウトボックス配信Lintルールにおけるループ処理
  • Jae Hui Hong (@axz0612): 抑制されたボキャブラリ失敗に対する警告レベルのログ記録
  • Heewon Chae (@heeoneie): 公開鍵およびHTTP Message Signatures仕様キャッシュの期限切れ機能

2.3.0以降、ChanHaeng Lee (@2chanhaeng) とJiwon Kwon (@z9mb1) がメンテナーとして加わりました。ChanHaengはバックグラウンドタスクAPIと @fedify/pglite を構築し、Jiwonは NetlifyBlobsKvStore を構築してリレーのドキュメントを書き直しました。また、両名は本リリースのその他の部分についても多くをレビューしました。

本開発サイクル中には、good first issueなどを起点として、多くの方々がテスト、ドキュメント、およびインフラストラクチャを追加してくれました: Seoa Yoon (@bananamilk452)、Lim Kyoujin (@dktsudgg)、@m2nsp、Cho Seongmin (@seongmin36)、Safal Shrestha (@Safal-Shrestha-SS)、Jo Youngjae (@jaonz6057)、@kecan0406、Loi Nguyen (@lntutor)、Oh Taejun (@0w0)、Yerin Park (@dstforye)、Lee Dogeon (@moreal)、Sae Jin Kim (@siliconsjang)。

FEP-ef61に関する取り組みは、プロジェクト外部の多くの方々に支えられています。@anotherdoesnm は #288 でリクエストを提起しました。FEP-ef61およびFEP-ae97の作成者である @silverpill は、ゲートウェイの信頼モデルを明確化し、複合証明プロファイルをレビューし、そのテストベクターを確認しました。@dmitrizagidulin と @by_caballero は、#938 においてプロファイルのJSON-LDに関する検討に意見を寄せました。tootikおよびMitraに対するテストは、本リリースにおける2つの相互運用性修正へとつながりました。

完全な変更履歴については CHANGES.md を参照してください。

한국어:

자동 번역 · 원문 읽기


Fedify는 ActivityPub 서버 구축을 위한 TypeScript 프레임워크입니다. HTTP Signatures, JSON-LD 처리, WebFinger, 인박스 및 아웃박스 라우팅, 액티비티 전송 등과 같은 연합 세부 사양을 구현합니다.

Fedify 2.4.0은 변경 로그가 이례적으로 긴 마이너 릴리스입니다. 주요 추가 사항은 FEP-ef61 이식 가능한 객체(portable objects)에 대한 지원입니다. 이제 Fedify 서버는 도메인이 아닌 DID를 식별자로 사용하는 액터를 호스팅하고, 이들의 서명된 객체를 제공 및 검증하며, 다른 서버의 이식 가능한 객체를 사용할 수 있습니다. 또한 이번 릴리스에는 애플리케이션 정의 백그라운드 작업을 위한 API, ActivityPub 미디어 업로드 확장 사양, 각 인박스 요청이 어떻게 인증되고 처리되었는지 보고하는 훅, 삭제된 객체에 대한 410 Gone 툼스톤(tombstones) 기능이 추가되었습니다. 적대적인 피어가 Fedify에서 수행할 수 있는 동작을 제한하기 위해 몇 가지 기본값도 변경되었습니다. 이번 버전과 함께 네 가지 신규 패키지가 제공됩니다: @fedify/adonisjs, @fedify/interaction-controls, @fedify/netlify, @fedify/pglite.

대부분의 애플리케이션은 코드 수정 없이 업그레이드할 수 있으나, 새롭게 변경된 일부 기본값으로 인해 동작 방식의 변화가 있습니다. 해당 내용은 마지막 업그레이드 섹션에 정리되어 있습니다.

FEP-ef61 이식 가능한 객체

ActivityPub 객체의 ID는 해당 서버 도메인의 HTTP(S) URL입니다. 해당 서버가 다운되면 ID의 리졸브(해결)가 중단되며, 다른 곳으로 이전하는 액터는 새로운 ID를 부여받아 모든 사람이 다시 팔로우해야 합니다. Mitra의 @silverpill@mitra.social이 작성한 FEP-ef61은 서버 이름을 전혀 포함하지 않는 ID를 제안합니다. 이식 가능한 ID는 기관(authority) 부분이 DID로 구성된 ap: URI입니다:

ap://did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2/actor

동일한 객체를 *게이트웨이(gateways)*라고 불리는 여러 서버에 저장할 수 있으며, 각 게이트웨이는 /.well-known/apgateway/ 경로 하위에서 해당 객체를 제공합니다. 어떤 서버도 ID를 소유하지 않으므로, 이식 가능한 액터, 액티비티 또는 객체는 ID에 포함된 DID 키로 생성된 FEP-8b32 객체 무결성 증명(Object Integrity Proof)을 통해서만 인증됩니다. 이식 가능한 액터는 gateways 속성에 자신의 게이트웨이를 나열하며, 다른 서버는 이를 통해 해당 액터의 인박스로 액티비티를 전송합니다.

Fedify 2.4.0은 게이트웨이 및 이식 가능한 객체 소비자를 위한 FEP-ef61 *프로파일(profile)*을 구현합니다. FEP 또는 기반 사양에서 명확히 규정하지 않은 부분에 대해 의도적인 두 가지 설계를 채택했으며, 제안서의 일부 요소는 제외되었습니다. 두 가지 모두 아래에 설명되어 있습니다. 매뉴얼의 새로운 이식 가능한 객체 장에서는 이식 가능한 액터를 처음부터 끝까지 실행하는 과정을 안내한 후 프로파일 전체를 설명하므로, 다른 구현자들은 Fedify 서버에서 기대할 수 있는 동작을 파악할 수 있습니다. 이 기능은 #288에서 @anotherdoesnm의 요청으로 시작되어 로드맵부터 최종 구현까지 추적 및 진행되었습니다.

이식 가능한 객체는 노매딕 아이덴티티(nomadic identity)를 위한 기반일 뿐, 그 자체로 노매딕 아이덴티티를 의미하지는 않습니다. Fedify는 이식 가능한 객체를 제공 및 검증하고 게이트웨이 간에 액티비티를 전달하지만, 서버 간에 액터나 게시물을 복사하거나 키를 교체하거나 액터를 다른 DID로 이전하지는 않습니다. 완전한 노매딕 아이덴티티는 #413에서 여전히 논의 중입니다.

이식 가능한 액터 호스팅하기

이식 가능한 액터는 Ed25519 키 쌍에서 시작됩니다. 해당 액터의 DID는 exportDidKey()를 통해 공개키로부터 생성되며, 개인키를 보유한 주체는 누구나 해당 액터로 동작할 수 있으므로 액터와 함께 안전하게 보관해야 합니다:

import { generateCryptoKeyPair } from "@fedify/fedify";
import { exportDidKey } from "@fedify/vocab-runtime";

const { publicKey, privateKey } = await generateCryptoKeyPair("Ed25519");
const did = await exportDidKey(publicKey); // did:key:z6Mk...
// 액터의 증명이 참조하는 검증 메서드:
const didKeyId = new URL(`${did}#${did.slice("did:key:".length)}`);

액터 디스패처는 일반 액터를 반환하듯이 이식 가능한 액터를 반환합니다. Context.getPortableActorUri() 및 인박스, 아웃박스, 컬렉션, 객체를 위한 관련 메서드들은 디스패처의 경로와 DID로부터 이식 가능한 ID를 구성하며, signObject()는 DID의 키로 액터에 서명합니다:

federation
 .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
 const user = await findUser(identifier);
 if (user == null) return null;
 // 게이트웨이 엔드포인트를 통한 요청은 경로에 DID를 지정합니다.
 // 예: GET /.well-known/apgateway/did:key:z6Mk.../users/alice:
 if (
 ctx.portableRequest != null &&
 ctx.portableRequest.authority !== user.did
 ) {
 return null;
 }
 // 해당 액터에 대한 이 서버의 게이트웨이 키(아래 참조):
 const keys = await ctx.getActorKeyPairs(identifier);
 return await signObject(
 new Person({
 id: ctx.getPortableActorUri(identifier, user.did), // ap+ef61://did:key:z6Mk.../users/alice
 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,
 new URL(`${user.did}#${user.did.slice("did:key:".length)}`),
 );
 })
 .setKeyPairsDispatcher(async (ctx, identifier) => {
 const user = await findUser(identifier);
 // DID의 키가 아닌, 해당 액터에 대한 이 서버 자체의 키 쌍:
 return user == null ? [] : [user.gatewayKeyPair];
 })
 .mapPortableActorId(async (ctx, identifier) => {
 const user = await findUser(identifier);
 return user == null ? null : ctx.getPortableActorUri(identifier, user.did);
 });

이 디스패처를 통해 Fedify는 다른 서버들이 액터를 조회하는 방식인 호환 식별자 https://example.com/.well-known/apgateway/did:key:z6Mk.../users/alice에서 액터를 제공합니다. 핸들의 도메인은 gateways의 첫 번째 게이트웨이에서 가져오므로 WebFinger를 통해서는 @alice@example.com으로 제공되며, 이전과 마찬가지로 일반 URL에서도 제공됩니다. 게이트웨이 엔드포인트에서 Fedify는 문서의 ID가 요청된 이식 가능한 ID와 일치하고 해당 DID의 키로 생성된 증명을 포함하는 경우에만 문서를 제공합니다.

키 쌍 디스패처와 새로운 ActorCallbackSetters.mapPortableActorId()는 액터에게 게이트웨이 키를 부여합니다. 이는 액터에 대해 서버가 자체적으로 보유하는 키로, 전송 등 서버가 액터를 대신하여 수행하는 HTTP 요청에만 서명합니다. 이 키는 액터의 액티비티나 객체에는 절대 서명하지 않으며, 해당 작업은 오직 DID의 키로만 수행됩니다. FEP-521a에 명시된 대로 다른 서버가 HTTP Signatures를 검증할 수 있도록 액터 문서는 이 키들을 assertionMethods 아래에 나열합니다. 자세한 내용은 액터 디스패치하기 및 이식 가능한 액터의 게이트웨이 키를 참조하십시오.

객체 및 컬렉션 디스패처도 동일한 방식으로 DID 뒤의 경로에 따라 라우팅하여 게이트웨이 엔드포인트를 통해 이식 가능한 객체와 컬렉션을 제공합니다. 두 가지 규칙이 액터의 데이터를 보호합니다. 비공개 이식 가능한 객체는 디스패처에 인가 조건자(authorization predicate)가 있는 경우에만 제공되는데, FEP-ef61에서 게이트웨이가 수신 대상 외부의 누구에게도 이를 공개하는 것을 금지하기 때문입니다. 새로운 RequestContext.isSignedByAudience()를 사용하면 이를 쉽게 작성할 수 있습니다. 조건자가 없으면 게이트웨이에 해당 객체가 저장되어 있지 않은 것처럼 404 Not Found가 반환됩니다. 삭제된 객체는 서명된 Tombstone과 함께 410 Gone으로 제공됩니다. GET /.well-known/apgateway/hl:zQm...과 같이 이식 가능한 객체가 SHA-256 해시링크(hashlinks)로 참조하는 리소스는 새로운 Federatable.setHashlinkMediaDispatcher()를 통해 제공됩니다.

송신 및 수신

이식 가능한 액터의 액티비티는 해당 DID로 서명되어야 하지만, Fedify의 키 쌍 디스패처는 이 정보를 알지 못합니다. 액티비티를 전송하기 전에 signObject()로 서명하고, 액티비티가 증명이 적용된 형태로 전송될 수 있도록 normalizeExistingProofs: true를 전달해야 합니다. Create의 Note와 같이 이식 가능한 객체를 내포하는 액티비티는 안쪽에서 바깥쪽 순서로 서명합니다:

const actor = withGatewayHints(
 ctx.getPortableActorUri(user.identifier, user.did),
 [new URL("https://example.com")],
); // ap+ef61://did:key:z6Mk.../users/alice?@gateway=https%3A%2F%2Fexample.com
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,
 didKeyId,
);
const create = await signObject(
 new Create({
 id: ctx.getPortableObjectUri(
 Note,
 { identifier: user.identifier, id: crypto.randomUUID() },
 user.did,
 ),
 actor,
 to: PUBLIC_COLLECTION,
 object: note,
 }),
 user.didKeyPair.privateKey,
 didKeyId,
);
await ctx.sendActivity(
 { identifier: user.identifier },
 "followers",
 create,
 { normalizeExistingProofs: true },
);

이 예제는 FEP-ef61 외부에서도 유용한 signObject()의 수정 사항에 기반합니다. 이전에는 서명된 객체가 타입이 지정된 부모 객체에 할당될 때, 부모 객체가 직렬화되는 과정에서 부모의 JSON-LD 컨텍스트에 맞춰 재구성되었습니다. 이로 인해 자식 객체는 proofValue는 유지하지만 증명이 적용되었던 원래 문서를 잃어버려 자체 검증이 불가능해졌습니다. 이제 signObject()는 서명 대상이 된 정확한 문서를 캡처하며, 중첩 직렬화 시 해당 문서를 그대로 내포합니다. 캡처된 문서는 스냅샷 형태입니다. 즉, clone()은 이를 유지하지 않으므로 복제본을 내포해야 하는 경우에는 다시 서명해야 합니다. 관련 설계는 #1044에, 수정 내용은 #1051에 정리되어 있습니다.

Context.sendActivity()는 서명되지 않았거나 잘못된 키로 서명되었거나 Linked Data Signature를 포함하는 이식 가능한 액티비티를 거부하며, 대기열에 추가되기 전에 TypeError를 발생시킵니다. 또한 수신된 서명된 Follow가 내포된 Accept처럼 Fedify 자체 인박스에서 거부될 수 있는 몇 가지 형태의 복합 문서도 거부합니다. 이러한 객체는 ID로 대신 참조해야 합니다. 이식 가능한 인박스로의 전송은 수신자의 게이트웨이나 인박스의 @gateway 힌트를 통해 이루어지며, 전송이 수락될 때까지 최대 5곳을 시도합니다. 이번 릴리스 이전에는 UrlError: Unsupported protocol: ap+ef61: 에러와 함께 실패했습니다. 관련 규칙은 이식 가능한 액터로 전송하기 및 복합 문서 생성하기에 설명되어 있습니다.

수신 측에서는 이식 가능한 인박스가 POST /.well-known/apgateway/did:key:z6Mk.../users/alice/inbox 형태로 호출되며, 일반 인박스와 동일한 인박스 리스너로 디스패치됩니다. Context.parseUri()에 portable 옵션이 추가되었으며, 파싱 결과의 새로운 authority 속성에 DID가 포함되어 저장된 값과 대조 검증할 수 있습니다. Fedify는 해당 DID의 유효한 증명이 있는 경우에만 이식 가능한 액터의 액티비티나 이식 가능한 ID를 가진 액티비티를 수락하며, HTTP Signature나 Linked Data Signature로는 인증되지 않습니다. 액티비티에 내포된 각 이식 가능한 객체는 디스패치되기 전에 자체 증명을 기반으로 검증됩니다. 자체 증명을 포함하는 수락된 액티비티는 FEP-ef61 권장 사항에 따라 액터의 다른 게이트웨이로 최대 한 번 전달(포워딩)됩니다. 전달 기능은 cas()를 지원하는 키-값 저장소가 필요하며, FederationOptions.portableInboxForwarding 옵션을 통해 설정할 수 있습니다. 자세한 내용은 이식 가능한 인박스 및 다른 게이트웨이로 전달하기를 참조하십시오.

이식 가능한 객체 소비하기

이식 가능한 액터를 전혀 호스팅하지 않는 애플리케이션이라도 다른 서버의 이식 가능한 객체를 접하게 됩니다. Context.lookupObject()는 이식 가능한 ID, 호환 식별자 및 이식 가능한 액터의 연합우주 핸들을 리졸브하며, 검증된 경우에만 이식 가능한 객체를 반환합니다:

const actor = await ctx.lookupObject("@alice@example.com");
// @gateway 힌트가 없는 이식 가능한 ID는 조회할 게이트웨이가 필요합니다:
const note = await ctx.lookupObject("ap://did:key:z6Mk.../notes/1", {
 gateways: ["https://example.com"],
});

속성 접근자(property accessors) 역시 게이트웨이를 통해 이식 가능한 참조를 가져오며, 새로운 verifyPortableObject 옵션을 통해 가져온 대상을 검증합니다. Context에는 그에 대응하는 verifyPortableObject 속성이 있으므로, await create.getObject(ctx)와 같이 옵션으로 컨텍스트를 전달하기만 하면 충분합니다. 인박스에서 수신된 액티비티는 기본적으로 이 검증기를 사용하며, 접근자를 통해 가져온 객체도 이를 상속하므로 (await create.getObject(ctx))?.getAttribution()과 같은 체이닝 호출 과정에서 경로상의 각 이식 가능한 객체를 검증합니다. 조회 시 최대 5개의 게이트웨이에 요청하며, 한 곳이 실패하거나 검증되지 않는 항목을 제공하면 다음 게이트웨이로 폴백합니다. 서명되지 않은 컬렉션은 FEP-ef61에서 허용하는 바와 같이 소유자가 나열한 게이트웨이로부터 제공된 경우, 그리고 액터의 inbox, outbox, followers, following, liked에 대해서만 수락됩니다. fetchPortableMedia()는 이식 가능한 객체의 미디어를 조회하며, 객체의 digestMultibase와 일치하는 경우에만 반환합니다. 자세한 내용은 이식 가능한 참조 역참조하기 및 기본 검증기를 참조하십시오.

호환 식별자는 더 이상 오리진(origin)을 기준으로 신뢰하지 않습니다

ap: URI를 해석하지 못하는 소프트웨어는 https://gw.example/.well-known/apgateway/did:key:z6Mk.../actor와 같이 게이트웨이의 /.well-known/apgateway/ 경로 하위에 있는 HTTP(S) URL인 호환 식별자를 통해 이식 가능한 객체를 참조할 수 있습니다. tootik을 포함한 일부 구현체는 이식 가능한 액터의 ID로 호환 식별자를 사용합니다.

이전 버전의 Fedify는 이러한 URL을 일반 웹 URL로 취급하고 이를 제공한 서버를 기준으로 신뢰했습니다. 누구나 어떤 DID에 대해서든 호환 식별자를 제공할 수 있으므로, https://evil.example/.well-known/apgateway/did:key:z6MkAlice/actor가 앨리스의 DID로 서명된 증명 없이도 앨리스 행세를 할 수 있었습니다. Fedify 2.4.0은 FEP-ef61의 요구 사항에 따라 호환 식별자를 해당 식별자가 나타내는 이식 가능한 객체로 취급합니다. 인박스는 액터의 DID로 생성된 유효한 객체 무결성 증명(Object Integrity Proof)이 없는 한, ID가 호환 식별자인 액터로부터 온 액티비티를 401 Unauthorized로 거부하며, 이 동작을 비활성화하는 옵션은 없습니다. 서명 없이 호환 식별자를 발행하는 액터는 더 이상 수락되지 않으며, Fedify에서 아직 검증할 수 없는 키 유형을 사용하는 DID 액터도 마찬가지로 수락되지 않습니다. 이 변경 사항은 #1093에서 추적되었고 #1105에서 구현되었습니다.

ap: ID를 거부하는 소프트웨어를 위해, 개발자의 고유한 이식 가능한 액터는 여전히 호환 식별자를 액터 ID로 사용할 수 있습니다. 서명하기 전에 toCompatibleEf61Id()로 이를 생성하면, Fedify는 해당 객체를 제공하거나 전송하는 모든 위치에서 이를 이식 가능한 객체로 취급합니다. 또한 식별자의 형식이 잘못되었거나 액터의 첫 번째 게이트웨이를 가리키지 않는 경우 경고를 표시합니다. 자세한 내용은 액터 ID로서의 호환 식별자를 참조하십시오.

Fedify 프로파일이 FEP와 다른 점

Fedify 프로파일의 두 가지 선택 사항은 현재 FEP 본문과 다르거나 아직 확정되지 않은 시맨틱에 기반합니다. Fedify는 2.x 시리즈에서 이 두 가지를 유지하며, Fedify 3.0에서 변경될 수 있습니다.

첫 번째는 스킴(scheme)입니다. FEP-ef61은 ap:를 권장하고 ap+ef61:을 ap:로 정규화(canonicalize)하지만, 이러한 ID가 이식 가능한 객체 전용이므로 권장 사항이 ap+ef61:로 변경될 수 있다고도 명시하고 있습니다. Fedify는 두 가지를 모두 수락하여 동일한 것으로 비교하며, 직렬화할 때는 ap+ef61:을 사용합니다. 문서를 파싱하면 이식 가능한 IRI가 ap+ef61:로 정규화되므로, 다른 구현체에서 ap: ID로 서명한 객체를 재직렬화하면 해당 증명이 깨집니다. 이러한 문서의 수신된 JSON을 그대로 보관하고, 이식 가능한 ID는 문자열 형태가 아닌 arePortableUrisEqual()을 사용하여 비교하십시오. 관련 논의는 #826 및 #828에 있으며, 그 배경은 정규 ap+ef61: 스킴에 설명되어 있습니다.

두 번째는 복합 문서(compound documents)와 관련된 부분입니다. 서명된 Create 내부에 서명된 Note가 위치할 때, 검증기는 JSON의 어느 부분을 각 증명이 포괄하는지 알아야 하지만 FEP-8b32와 Verifiable Credential Data Integrity 모두 아직 이를 정의하지 않았습니다. 이 문제는 w3c/vc-data-integrity#350에서 열려 있습니다. Fedify는 #938에서 마련된 임시적인 맵 로컬(map-local) 프로파일을 사용합니다. proof가 있는 각 JSON 맵은 그 자체로 독립적인 보안 문서이며, 내포된 각 이식 가능한 객체는 고유한 증명과 고유한 @context를 필요로 하고, 증명 세트(proof sets) 및 증명 체인(chains)은 지원되지 않습니다. 이 프로파일은 각 증명이 포괄하는 정확한 JSON 값들을 인증합니다. 부모 내부에서 자식의 JSON-LD 확장이 단독으로 확장된 결과와 일치한다는 보장은 제공하지 않습니다. @silverpill, @dmitrizagidulin, @by_caballero@mastodon.social이 해당 스레드에서 제안서를 검토했으며, @silverpill@mitra.social이 해당 테스트 벡터를 독립적으로 재현했습니다. 자세한 내용은 맵 로컬 복합 증명을 참조하십시오.

구현되지 않은 사항

Fedify는 클라이언트 측 서명을 위한 관련 제안서인 FEP-ae97을 구현하지 않습니다. 액터 등록, 클라이언트가 서명한 액티비티를 이식 가능한 아웃박스에 제출, 게이트웨이 탐색을 위한 엔드포인트가 없으며, /.well-known/apgateway-media 하위의 미디어 업로드 및 삭제 엔드포인트도 제공되지 않습니다. 개발자의 자체 아웃박스 리스너는 클라이언트가 서명한 액티비티를 변경 없이 그대로 중계할 수 있습니다. 또한 Fedify는 게이트웨이 간에 액터, 객체, 컬렉션을 복제하거나 동기화(reconcile)하지 않으므로, 여러 게이트웨이에서 액터를 운영하려면 데이터를 직접 동기화 상태로 유지해야 합니다. DID 키를 교체하거나 액터를 다른 DID로 마이그레이션하기 위한 워크플로는 없습니다. Fedify는 Ed25519 키가 포함된 did:key DID에 대해서만 검증 메서드를 리졸브하며, 다른 DID 메서드는 개발자의 문서 로더(document loader)가 이를 리졸브하는 경우에만 검증됩니다. FEP-ef61에서 논의된 임의의 게이트웨이 경로는 지원되지 않으므로 게이트웨이는 순수한 오리진(plain origins)이어야 합니다. 전체 목록은 지원되지 않는 항목에 있습니다.

상호 운용성

이 프로파일은 #1202에서 tootik v0.25.4 및 Mitra v5.10.0을 대상으로 테스트되었습니다. Fedify는 tootik의 이식 가능한 액터, 액티비티, HTTP Signatures를 검증하며, 클라이언트가 서명한 액티비티에 대한 Mitra의 게이트웨이 HTTP Signatures를 검증합니다. 두 구현체 모두 Fedify가 전송하는 이식 가능한 액티비티를 수락합니다. 이 테스트 과정에서 두 가지 수정 사항이 도출되었습니다. Fedify는 이제 tootik이 JSON-LD 컨텍스트에서 매핑되지 않은 상태로 남겨둔 gateways 목록을 정상적으로 읽습니다. 또한 Mitra가 후행 슬래시를 거부하기 때문에 게이트웨이를 순수한 오리진(bare origins)으로 직렬화합니다. Fedify는 Mitra가 자체 서명한 액터나 액티비티를 아직 검증해보지 않았으며, Mastodon의 이식 가능한 액터 처리 방식은 테스트되지 않았습니다. 무엇을 어떻게 테스트했는지는 FEDERATION.md의 FEP-ef61 상호 운용성 섹션에 기록되어 있습니다.

툴링

fedify lookup은 ap: 및 ap+ef61: ID, 호환 식별자, 이식 가능한 액터의 핸들을 통해 이식 가능한 객체를 조회하고 그 증명을 검증합니다. 어떤 게이트웨이도 수락 가능한 객체를 반환하지 않는 경우 그 이유를 출력합니다. fedify lookup, fedify inbox, fedify webfinger의 새로운 --gateway 옵션은 조회할 게이트웨이를 지정합니다. fedify inbox --follow는 이식 가능한 액터를 팔로우하며, fedify webfinger는 이식 가능한 액터 ID를 수락합니다. CLI 매뉴얼의 이식 가능한 객체 조회하기를 참조하십시오.

@fedify/testing의 모의(mock) 연합 및 컨텍스트는 이식 가능한 ID, 픽스처 문서 로더(fixture document loaders)를 통한 검증, 게이트웨이 요청, 해시링크 미디어를 지원하므로 실제 구동 중인 게이트웨이 없이도 테스트에서 이식 가능한 객체를 다룰 수 있습니다. @fedify/lint의 액터 URI 규칙은 getPortable*Uri() 메서드가 빌드한 이식 가능한 ID를 허용합니다. @fedify/next는 이제 해시링크 미디어 요청을 Fedify로 전달하며, 이는 #1149에서 보고되어 #1170에서 수정되었습니다. Next.js에서 이를 위한 미들웨어를 실행하도록 하려면 matcher에 { source: "/.well-known/apgateway/:path*" }를 추가하십시오.

백그라운드 작업

Fedify는 이미 큐 워커를 통해 액티비티 전송과 인박스 처리를 실행하고 있지만, 애플리케이션에는 다이제스트 이메일, 타임라인 재구성, 링크 미리보기와 같은 자체적인 작업이 있는 경우가 많습니다. 지금까지는 이러한 작업에서 Fedify의 큐, 직렬화 또는 재시도 기능을 사용할 수 없었습니다. Fedify 2.4.0은 애플리케이션 정의 작업에도 동일한 메커니즘을 개방합니다.

작업은 defineTask()를 통해 한 번 정의되며, 모든 Context에서 enqueueTask() 또는 enqueueTaskMany()를 사용하여 큐에 넣을 수 있습니다. 모든 작업은 페이로드의 타입을 지정하는 Zod나 Valibot 스키마와 같은 Standard Schema를 사용합니다. Fedify는 페이로드가 큐에 들어갈 때, 그리고 워커가 이를 가져올 때 다시 한 번 유효성을 검사합니다. 두 번째 검사는 이전 배포 버전에서 생성되어 더 이상 스키마와 일치하지 않는 페이로드를 포착합니다:

import { createExponentialBackoffPolicy } from "@fedify/fedify";
import { z } from "zod";

const sendDigest = federation.defineTask("sendDigest", {
 schema: z.object({ userId: z.string(), since: z.date() }),
 handler: async (ctx, data) => {
 // data의 타입은 { userId: string; since: Date }입니다.
 await sendEmail(data.userId, await buildDigest(data.userId, data.since));
 },
 retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }),
});

// Context가 있는 모든 위치에서:
await ctx.enqueueTask(sendDigest, { userId: "alice", since: new Date() }, {
 delay: { minutes: 30 },
 deduplicationKey: "digest:alice",
});

Fedify는 devalue를 사용하여 페이로드를 직렬화하므로, Date, Map, Set, URL, bigint, Temporal 값, 순환 참조 및 Activity Vocabulary 객체 모두가 모든 메시지 큐 백엔드를 거치는 과정에서도 유지됩니다. 페이로드에 포함된 Note는 그대로 Note로 복원됩니다. 실패한 핸들러는 기본적으로 지수 백오프(exponential backoff)를 통해 재시도되며, 자체 재시도 기능이 내장된 큐는 재시도를 직접 처리합니다. 작업을 위한 별도의 큐를 지정하지 않으면 작업은 아웃박스 큐로 폴백합니다. 별도의 큐를 지정하려면 queue 옵션의 새로운 task 슬롯을 사용하거나 작업별 queue를 사용하며, startQueue(ctxData, { queue: "task" })를 호출하면 작업 전용 워커가 실행됩니다. taskQueueResolution: "strict"를 설정하면 이 폴백이 에러로 처리됩니다.

deduplicationKey는 중복 인큐를 방지합니다. 이 기능은 작업이 이미 큐에 들어가 있는지 여부를 알 수 없을 때 반복 작업을 관리하기 어렵다는 #206의 @julian@fietkau.social의 지적에서 비롯되었습니다. nativeDeduplication을 선언한 큐는 키를 자체적으로 강제 적용하며, 그렇지 않은 경우 Fedify는 cas()를 통해 키-값 저장소에 최선형(best-effort) 마커를 유지합니다. 각 작업은 fedify.task 스팬에서 실행되며, 2.3.0에 추가된 큐 메트릭에 이제 작업 큐도 포함됩니다. 백그라운드 작업 장에서는 라우팅, 재시도, 중복 제거 및 제한 사항을 다룹니다. cron 스타일의 스케줄링, 결과 백엔드 및 우선순위는 현재 범위에서 제외되어 있습니다.

이 API는 이찬행(@2chanhaeng) 님이 #797, #798, #799의 계획에 따라 #803에서 코어를, #806에서 중복 제거를, #812에서 관찰 가능성을 구현했습니다. 해당 작업은 #923에서 병합되어 2025년 2월부터 열려 있던 #206을 닫았습니다.

미디어 업로드

Fedify 2.2.0에서 클라이언트-서버(C2S) 아웃박스 리스너가 추가되었지만, C2S 클라이언트는 이미지를 게시하기 전에 이미지와 비디오를 먼저 업로드할 수도 있어야 합니다. 해당 트래픽은 POST /outbox를 거치지 않습니다. Fedify 2.4.0은 2017년에 마지막으로 업데이트되었으며 W3C 권고안 상태에는 이르지 못한 SocialCG 제안서인 ActivityPub 미디어 업로드 확장 사양을 지원합니다.

setMediaUploader()는 multipart/form-data 엔드포인트를 등록합니다. 이 콜백은 업로드된 file과 클라이언트가 함께 게시한 object 셸을 수신합니다. 콜백이 생성된 객체를 반환하면 Fedify는 201 Created로 응답하며, 처리가 완료된 후 객체가 위치하게 될 URL을 반환하면 Fedify는 202 Accepted로 응답합니다:

federation
 .setMediaUploader(
 "/users/{identifier}/media",
 async (ctx, identifier, file, object) => {
 const stored = await uploadToStorage(file);
 return new Image({
 id: ctx.getObjectUri(Image, { uuid: stored.uuid }),
 url: new URL(stored.publicUrl),
 mediaType: file.type,
 name: object.name,
 });
 },
 )
 .authorize(async (ctx, identifier) => {
 const session = await verifyAccessToken(
 ctx.request.headers.get("authorization"),
 );
 return session?.identifier === identifier;
 });

액터 디스패처는 인박스 및 아웃박스 URI를 배치하는 것과 동일한 방식으로 endpoints: new Endpoints({ uploadMedia: ctx.getMediaUploaderUri(identifier) })를 설정하여 엔드포인트 자체를 알립니다. authorize() 훅이 없으면 엔드포인트를 찾는 누구나 업로드할 수 있습니다. 이러한 이유로 Fedify는 훅 없이 업로더가 등록되었을 때, 반환된 객체의 URI가 객체 디스패처와 일치하지 않을 때, 또는 액터가 엔드포인트를 알리지 않을 때 경고 로그를 기록합니다. 네 가지 새로운 @fedify/lint 규칙이 실행 전 동일한 실수를 감지합니다. Fedify는 업로드된 데이터를 메모리로 읽어 들이며 자체적인 크기 제한을 두지 않으므로 리버스 프록시에서 요청 크기를 제한해야 합니다. 파일을 저장하고 객체를 다시 제공하는 것은 애플리케이션의 몫입니다. 미디어 업로드 장을 참조하십시오. 관련 작업은 #754 및 #927에 있습니다.

인박스 요청 리포트

Fedify는 모든 인박스 전송에 대해 적용된 서명 메커니즘, 시도된 키, 액터의 키 소유 여부, 액티비티에 일어난 결과 등 여러 사항을 결정합니다. 이번 릴리스 이전에는 애플리케이션에서 이러한 정보의 일부만 확인할 수 있었습니다. 액터가 소유하지 않은 키로 생성된 Linked Data Signature는 잘못된 서명과 동일하게 취급되었으며, 요청을 검증한 키가 애플리케이션에 전달되지 않았습니다. #1191의 요청은 디버깅 도구를 위해 이러한 결정 사항을 로깅하려는 시도에서 비롯되었습니다.

인박스 리스너의 새로운 onRequestFinished() 콜백은 거부된 요청과 검증 전에 실패한 요청을 포함하여, 인박스 라우트로 들어오는 모든 전송에 대해 InboxRequestReport를 수신합니다:

federation
 .setInboxListeners("/users/{identifier}/inbox", "/inbox")
 .on(Follow, async (ctx, follow) => { /* ... */ })
 .onRequestFinished(async (ctx, report) => {
 await db.insertInboxLog({
 inbox: report.inbox.kind, // "personal" | "shared" | "portable"
 authentication: report.authentication.status,
 outcome: report.outcome.type === "response"
 ? report.outcome.disposition // "processed" | "enqueued" | "duplicate" | ...
 : `exception:${report.outcome.stage}`,
 keyIds: report.attempts
 .flatMap((attempt) => attempt.checks)
 .flatMap((check) => check.triedKeys)
 .map((key) => key.id?.href),
 });
 });

각 HTTP Signature, Linked Data Signature 또는 객체 무결성 증명(Object Integrity Proof) 검사는 만료된 캐시 키와 이를 갱신한 대체 키를 포함하여 실제 시도된 키들과 함께 시도(attempt) 내역으로 나타납니다. 리포트에는 actorKeyMismatch나 proofPolicy와 같은 최종 인증 결정 사항과 요청을 종료시킨 응답 또는 예외도 포함됩니다. Fedify는 트레이스가 샘플링되었는지 여부와 관계없이 콜백을 await합니다. 콜백에서 발생하는 에러는 응답을 변경하지 않고 로그에 기록되며, enqueued는 큐가 액티비티를 수락했음을 의미할 뿐 이후 워커가 이를 처리했음을 의미하지는 않습니다. @fedify/testing의 모의(mock) 연합도 동일한 콜백을 지원합니다. 인박스 요청 관찰하기를 참조하십시오. 구현 내용은 #1201에 있습니다.

삭제된 객체에 대한 툼스톤

액터 디스패처는 2.2.0부터 Tombstone을 반환할 수 있었지만, 객체 디스패처는 null만 반환할 수 있었습니다. 삭제된 게시물은 애초에 존재하지 않았던 것처럼 404 Not Found로 응답되었기 때문에, ActivityPub의 권장 사항대로 410 Gone을 사용하려는 애플리케이션은 Fedify 앞단에 자체 라우트를 두어야 했습니다.

이제 객체 디스패처가 Tombstone을 반환할 수 있으며, Fedify는 인가 조건자(authorization predicate)를 적용한 후 410 Gone 및 직렬화된 툼스톤과 함께 이를 제공합니다:

federation.setObjectDispatcher(
 Note,
 "/users/{identifier}/notes/{id}",
 async (ctx, values) => {
 const post = await getPost(values.identifier, values.id);
 if (post == null) return null;
 if (post.deletedAt != null) {
 return new Tombstone({
 id: ctx.getObjectUri(Note, values),
 formerType: Note,
 deleted: post.deletedAt,
 });
 }
 return new Note({ id: ctx.getObjectUri(Note, values), content: post.content });
 },
);

ObjectDispatcher의 반환 타입은 TObject | Tombstone | null로 확장됩니다. RequestContext.getObject()는 { tombstone: "passthrough" }를 전달하거나 Tombstone 또는 Object 클래스 자체를 요청하지 않는 한 툼스톤에 대해 여전히 null을 반환합니다. 이미 툼스톤을 반환하고 있던 Tombstone 또는 Object에 등록된 디스패처는 이제 200 대신 410으로 응답합니다. 삭제된 객체를 참조하십시오. 해당 변경 사항은 #1112에서 제안되어 #1117에서 구현되었습니다.

원격 피어가 Fedify에서 수행할 수 있는 동작에 대한 제한

Fedify가 조회하는 항목의 상당 부분은 수신된 요청의 송신자에 의해 결정됩니다. 서명에 포함된 키 ID가 대표적인 예이며, 그 배후에 있는 서버는 의도적으로 지연을 일으킬 수 있습니다. 2.4.0의 몇 가지 변경 사항은 이러한 작업에 제한을 둡니다.

#1130에서 지적된 바와 같이, 하나의 요청에 여러 RFC 9421 서명이 포함될 수 있으며, 각각의 서명으로 인해 Fedify가 송신자가 선택한 URL에서 키를 가져오게 될 수 있습니다. 이제 Fedify는 Signature-Input 순서에 따라 최대 처음 3개까지만 검증하고 나머지는 무시합니다. 키를 가져오기 전에 실패한 서명도 이 한도에 포함되며, 여러 서명에서 참조된 동일한 키는 한 번만 조회됩니다. #1166에서 이 제한과 함께 추가된 새로운 FederationOptions.maxHttpSignatures 옵션을 통해 이를 변경할 수 있습니다.

기본 내장된 문서 로더는 이제 각 호출에 기본적으로 10초의 제한 시간을 부여합니다. 이 제한은 #1131에서 제안되어 #1169에서 추가되었습니다. 이 시간 제한은 모든 리다이렉트, 대체 문서, 더블 노킹(double-knocking) 재시도 및 응답 본문 수신을 포괄합니다. 시간이 초과된 호출은 "TimeoutError"라는 이름의 DOMException을 cause로 갖는 FetchError를 발생시킵니다. 서버가 404 헤더를 보낸 후 본문 전송을 지연시키는 것을 방지하기 위해 로더가 에러를 발생시키기 전에 최대 1 MiB까지의 에러 응답을 완전히 읽어 들입니다. FederationOptions.documentLoaderTimeout을 사용하여 이 제한을 변경하거나 null로 비활성화할 수 있으며, 커스텀 문서 로더는 영향을 받지 않습니다. #913 이후로, getDocumentLoader()는 ActivityPub 대체 항목을 명시하지 않는 HTML 응답에 대해 JSON.parse() 내부에서 실패하는 대신 FetchError로 거부합니다. 이는 #912에서 에러 트래커를 불필요하게 어지럽히는 문제로 확인된 바 있습니다.

캐시된 공개키와 기억된 HTTP Message Signatures 사양은 이전까지 키-값 저장소에 영구적으로 유지되었습니다. #1017에 따르면 프로덕션 Redis 인스턴스에서 대부분 연합 활동을 중단한 액터들에 대한 공개키 항목이 194,424개(약 165 MiB)나 유지되고 있었습니다. 이제 키는 30일 후, 사양은 90일 후에 만료되며, FederationOptions.publicKeyTtl 및 FederationOptions.httpMessageSignaturesSpecTtl을 통해 설정할 수 있습니다. 이전 버전에서 작성된 항목은 다음에 다시 기록될 때까지 만료 시간이 적용되지 않습니다. 레거시 캐시 항목 정리하기 섹션에서 Redis, PostgreSQL 및 기타 저장소에 대한 삭제 방법을 안내합니다. 채희원(@heeoneie) 님이 Fedify에 대한 첫 기여로 #1027에서 이를 구현했습니다.

const federation = createFederation<void>({
 kv,
 maxHttpSignatures: 1, // 기본값: 3
 documentLoaderTimeout: { seconds: 5 }, // 기본값: 10초
 publicKeyTtl: { days: 7 }, // 기본값: 30일
 httpMessageSignaturesSpecTtl: { days: 30 }, // 기본값: 90일
});

마지막으로, verifyProof()는 이제 proofValue를 제외한 expires, domain, challenge, nonce 등 Ed25519 JCS 증명의 모든 옵션을 인증합니다. 또한 만료되었거나 형식이 잘못된 증명도 거부합니다. 이전에는 이미 서명된 증명에 expires와 같은 필드를 추가하더라도 증명이 무효화되지 않는 문제가 있었습니다. 권지원(@z9mb1) 님이 #968 코드 리뷰 과정에서 이를 발견했습니다. 이제 호출자는 VerifyProofOptions를 통해 domain과 challenge를 필수로 요구하여 크로스 도메인 사용이나 재전송(replay) 공격을 방지할 수 있습니다.

신규 패키지

@fedify/adonisjs

@fedify/adonisjs는 Fedify를 AdonisJS와 연동합니다. node ace add @fedify/adonisjs를 실행하면 연합의 수명 주기를 관리하는 서비스 프로바이더와 이를 마운트하는 서버 미들웨어가 등록됩니다. 또한 config/fedify.ts, start/federation.ts, app/federation/main.ts 파일의 스캐폴딩도 함께 생성됩니다. 컨트롤러는 ctx.federation 요청 컨텍스트를 사용할 수 있습니다:

// app/federation/main.ts
import federation from "@fedify/adonisjs/services/builder";
import { Person } from "@fedify/vocab";

federation.setActorDispatcher("/actors/{identifier}", (ctx, identifier) =>
 new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier }));

이 미들웨어는 AdonisJS의 라우터 스택이 아닌 서버 스택의 최상단에 배치됩니다. 이를 통해 Fedify는 라우터가 알지 못하는 /.well-known/webfinger와 같은 경로를 처리할 수 있으며, 바디 파서(body parser)가 요청 본문을 소비하기 전에 이를 읽어 들일 수 있습니다. 이 패키지는 Node.js를 대상으로 하며 npm에만 배포됩니다. 연동 가이드의 AdonisJS 섹션을 참조하십시오.

Samuel Brinkmann(@sabrinkmann) 님이 #1006에서 이 패키지를 기여하여 #139 이슈를 해결했으며, Emelia Smith(@thisismissem) 님이 해당 이슈에 첨부했던 프로토타입을 기반으로 구축되었습니다.

npm add @fedify/adonisjs
pnpm add @fedify/adonisjs
yarn add @fedify/adonisjs

@fedify/interaction-controls

GoToSocial 인터랙션 제어(interaction controls), FEP-044f 인용 인가, FEP-7aa9 추천 컬렉션은 요청 및 인가 플로우를 공유합니다. 어떤 서버가 게시물에 답글을 달거나, 인용하거나, 추천하기를 요청하면, 작성자의 서버는 게시물의 interactionPolicy에 따라 이를 승인합니다. @fedify/interaction-controls는 좋아요(like), 답글(reply), 아나운스(announce), 인용(quote), 추천(feature) 인터랙션에 대해 해당 플로우를 구현합니다. 각 인터랙션에는 likeInteraction 또는 quoteInteraction과 같은 헬퍼가 있어, 요청 및 인가를 생성하고 검증하며, 정책을 평가하고, 요청 없이 도착한 인터랙션을 식별합니다:

import { likeInteraction } from "@fedify/interaction-controls";

// LikeRequest가 수신되었을 때:
const verified = await likeInteraction.verifyRequest(ctx, { request });
if (verified.verified) {
 const decision = await likeInteraction.evaluatePolicy(ctx, {
 subject: note,
 requester: request.actorId!,
 });
 if (decision.result === "automatic") { // 또는 "manual" 또는 "denied"
 const authorization = likeInteraction.createAuthorization({
 id: new URL("https://example.com/authorizations/1"),
 attributedTo: owner,
 interactingObject: like,
 interactionTarget: note,
 });
 // ...
 }
}

이 패키지는 인박스 리스너를 설치하거나 데이터를 저장하지 않습니다. 요청과 인가가 어디에 저장되는지, 그리고 사용자의 직접 승인이 언제 필요한지는 애플리케이션에서 결정합니다. 검증은 안전 우선 방식(fail-closed)으로 실패 처리됩니다. 정책이 설정되어 있지 않은 경우 좋아요, 답글, 아나운스는 자동으로 승인되지만, 인용과 추천은 거부됩니다. @fedify/vocab에는 이에 필요한 FEP-7aa9 타입인 FeaturedCollection, FeaturedItem, FeatureRequest, FeatureAuthorization이 추가되었으며, 액터의 featuredCollections 및 InteractionPolicy.canFeature도 함께 추가되었습니다. Samuel Brinkmann 님이 Mastodon 4.6에서 해당 FEP를 지원함에 따라 #810에서 이를 요청했습니다. 인터랙션 제어 장을 참조하십시오. 관련 작업은 #914 및 #929에 있습니다.

npm add @fedify/interaction-controls
pnpm add @fedify/interaction-controls
yarn add @fedify/interaction-controls
deno add jsr:@fedify/interaction-controls
bun add @fedify/interaction-controls

@fedify/netlify

@fedify/netlify를 사용하면 외부 데이터베이스나 큐 없이도 Netlify Functions에서 Fedify 애플리케이션을 실행할 수 있습니다. NetlifyMessageQueue는 작업을 Netlify Async Workloads 이벤트로 제출하며, createNetlifyQueueHandler()는 Functions를 지연 전송, 네이티브 재시도, 키별 FIFO 순서 보장이 지원되는 소비자(consumer)로 변환합니다. NetlifyBlobsKvStore는 만료, 접두사 목록 조회, CAS(compare-and-swap)를 지원하는 Netlify Blobs 기반의 키-값 저장소입니다:

import {
 createNetlifyQueueHandler,
 NetlifyBlobsKvStore,
 NetlifyMessageQueue,
} from "@fedify/netlify";
import { AsyncWorkloadsClient } from "@netlify/async-workloads";
import { getStore } from "@netlify/blobs";

export const kv = new NetlifyBlobsKvStore(
 getStore({ name: "fedify", consistency: "strong" }),
);
export const queue = new NetlifyMessageQueue({
 client: new AsyncWorkloadsClient(),
 orderingKv: kv,
});

// netlify/functions/fedify-queue.ts
export default createNetlifyQueueHandler({
 queue,
 maxRetries: 4,
 federation: () => builder.build({ kv, queue, manuallyStartQueue: true }),
});

이 키-값 저장소는 비용 문제로 인해 만들어졌습니다. #1010에서는 정적 블로그의 ActivityPub 트래픽으로 인해 Netlify Database가 유휴 상태로 들어가지 못해 계정의 월간 크레딧 대부분을 소진한 사례가 설명되어 있습니다. 권지원 님이 #1029에서 NetlifyBlobsKvStore를 기여했으며, 큐 구현체는 #934에서 추가되었습니다. 배포 가이드의 Netlify Functions를 참조하십시오.

npm add @fedify/netlify
pnpm add @fedify/netlify
yarn add @fedify/netlify
deno add jsr:@fedify/netlify
bun add @fedify/netlify

@fedify/pglite

@fedify/pglite는 직접 생성하여 전달하는 임베디드 PGlite 데이터베이스 기반의 KvStore인 PgliteKvStore를 제공합니다. 이는 PostgresKvStore와 동일한 스키마를 사용합니다. 단일 런타임 격리 환경(isolate)의 단일 PGlite 인스턴스를 대상으로 하므로 메시지 큐는 포함되지 않습니다. PGlite는 프로세스 간에 데이터를 공유하지 않기 때문입니다.

import { PGlite } from "@electric-sql/pglite";
import { PgliteKvStore } from "@fedify/pglite";

const federation = createFederation({
 kv: new PgliteKvStore(new PGlite("./data/fedify")),
});

이찬행 님이 @fedify/testing의 testKvStore()와 함께 #1020에서 이 패키지를 기여했습니다. testKvStore()는 testMessageQueue()에 대응하는 KvStore 구현체를 위한 적합성 테스트 모음입니다. PgliteKvStore를 참조하십시오.

npm add @fedify/pglite
pnpm add @fedify/pglite
yarn add @fedify/pglite
deno add jsr:@fedify/pglite
bun add @fedify/pglite

PostgreSQL 및 Redis를 위한 CAS(compare-and-swap)

이식 가능한 인박스 전달(포워딩), 작업 중복 제거, Netlify 큐 순서 보장, 전송 서킷 브레이커는 모두 키-값 저장소의 원자적 CAS(compare-and-swap) 동작을 필요로 합니다. #934에서 추가된 PostgresKvStore와 #1163에서 요청되어 #1167에서 추가된 RedisKvStore가 이제 cas()를 구현합니다. Redis 버전은 단일 키 Lua 스크립트를 사용하므로 독립형(standalone) Redis와 Redis Cluster 모두에서 동작합니다. 이전까지 서킷 브레이커는 Redis에서 경쟁 상태(racy)를 유발하는 업데이트 방식으로 폴백했습니다.

두 구현체 모두 사용 조건이 있습니다. RedisKvStore는 Redis에서 EVAL 명령을 허용해야 하며, 커스텀 코덱은 동일한 값을 항상 동일하게 인코딩해야 합니다. 크래시 복구 과정에서 시퀀스 상태를 잃어버리면 큐에 대기 중인 이벤트가 영구적으로 차단될 수 있기 때문에, PostgresKvStore는 이제 기본적으로 로그 테이블(logged tables)을 생성합니다. 초기화 시 기존의 언로그드 테이블(unlogged tables)을 마이그레이션합니다. 이 1회성 마이그레이션은 테이블을 재작성하고 배타적 잠금(exclusive lock)을 획득하므로, 테이블이 크거나 사용량이 많은 경우 그에 맞춰 업그레이드 일정을 계획하십시오. unlogged: true를 설정하면 이전 동작 방식을 유지할 수 있습니다.

fedify init 및 CLI

fedify init은 이제 SvelteKit 프로젝트의 스캐폴딩을 지원하며, 이는 장하나래(@menele) 님이 #971에서 기여했습니다. 장하나래 님은 #990에서 생성된 프로젝트에 test 태스크도 추가했습니다. 이 태스크는 앱을 시작하고 준비될 때까지 대기한 후 로컬 액터를 정상적으로 리졸브하는지 확인하므로, 새 프로젝트에 대해 스캐폴딩 직후 및 추후 변경 사항 발생 시 실행할 수 있는 스모크 테스트(smoke test)를 제공합니다. 이제 fedify init은 생성 작업을 수행하기 전에 선택된 Deno, Bun 또는 Node.js가 Fedify의 최소 버전이나 Astro의 Node.js 22.12와 같은 프레임워크의 더 높은 요구 버전을 충족하는지 검사합니다. 비대화형 모드에서는 명확한 에러 메시지와 함께 실패하며, 대화형 모드에서는 영향을 받는 패키지 매니저를 비활성화합니다. 이정민(@userjmmm) 님이 #981에서 이 검사 기능을 기여했습니다. Astro 프로젝트는 이제 Astro 7을 사용하며, #931의 요청에 따라 #936부터 @fedify/astro는 Node.js, Deno, Bun 환경의 실제 빌드를 통해 Astro 5, 6, 7 버전을 대상으로 테스트됩니다.

fedify tunnel에 Fedify 프로젝트에서 운영하며 계정이 필요 없는 터널링 서비스인 fedify.com.es가 추가되었습니다. CLI는 로컬 포트를 노출하기 전에 SSH 호스트 키를 고정(pin)하고 일치하지 않는 서버를 거부합니다. 동일한 변경 사항인 #940에서 localhost.run은 서비스가 더 이상 존재하지 않아 제거되었습니다. fedify lookup은 이제 -T/--timeout이 지정되지 않은 경우 각 요청에 대해 10초 후 타임아웃 처리하며, 이 옵션은 이제 DNS 조회를 기다리는 시간도 제한합니다. #1197에서 CLI가 Optique 1.3.2로 전환되어 --timeout=30과 같이 붙여 쓴 값을 일관되게 처리하고, 잘못 입력한 옵션에 대해 더 나은 추천을 제공하며, -- 앞의 위치 인자(positional arguments) 뒤로 알려진 옵션에 대한 에러가 가려지지 않도록 개선되었습니다.

린트 규칙

@fedify/lint에는 위에서 언급된 미디어 업로드 관련 규칙 외에도 몇 가지 규칙이 추가되었습니다. #895에서 요청되어 #1022에서 추가된 actor-preferred-username-required는 액터 디스패처가 preferredUsername 없는 액터를 반환할 때 경고를 표시합니다. #1057 및 #1067에서 도출된 outbox-listener-delivery-not-awaited는 아웃박스 리스너가 ctx.sendActivity() 또는 ctx.forwardActivity()를 호출하고 프로미스(promise)를 무시할 때 이를 보고합니다. Cloudflare Workers에서는 응답이 반환되면 대기 중인 작업이 폐기되므로 이러한 액티비티가 전송되지 못할 수 있습니다. await, return, Promise.all(), Promise.allSettled(), waitUntil()은 프로미스를 처리한 것으로 간주되며, void, Promise.race(), Promise.any()는 대기하지 않겠다는 의도적인 선택으로 수용됩니다. 이 규칙은 ESLint의 recommended 설정에서는 경고로, strict 설정에서는 에러로 처리됩니다. Oxlint 사용자는 규칙 이름으로 활성화할 수 있으며, Deno Lint에서는 지원되지 않습니다.

#900을 해결한 #1050에서는 기존의 outbox-listener-delivery-required 규칙이 리스너 소스 코드를 텍스트로 단순 스캔하는 대신 제어 흐름(control flow)을 추적하기 시작했습니다. 이 규칙은 유일한 전송 호출이 데드 브랜치(dead branch) 내부에 있거나, 무조건적인 return이나 throw 뒤에 있거나, 전혀 사용되지 않는 함수 내에 위치하는 리스너를 보고하며, 판별할 수 없는 경우에는 조용히 유지됩니다. 장재혁(@jhyuk) 님이 이 변경 사항과 두 신규 규칙을 기여했습니다. 이후 archie(@ArchieTansaria) 님이 #1088에서 두 전송 관련 규칙이 정적으로 거짓인 루프와 루프 바인딩 패턴을 인식하도록 개선했습니다. 린트 가이드를 참조하십시오.

기타 변경 사항

  • @fedify/vocab은 번역을 위한 FEP-22cd 초안을 지원합니다. Translation은 번역된 버전의 언어, 번역자, 원본 객체 및 원본 검토 시간을 기록하며, 새로운 Object.translations 속성을 통해 별도의 게시물을 생성하지 않고도 이를 게시물에 첨부할 수 있습니다. 번역 메타데이터를 참조하십시오. (#1037, #1038)
  • @fedify/vocab-tools에 해당 용어가 사용될 때만 JSON-LD 컨텍스트를 추가하는 extraContext 속성 옵션과 trustEmbeddedObjects 타입 옵션이 추가되었습니다. (#1037, #1038)
  • suppressError: true로 처리되는 어휘집(Vocabulary) 조회 및 파싱 실패가 이제 에러 대신 경고로 기록되므로 더 이상 애플리케이션 에러로 나타나지 않습니다. 홍재희(@axz0612) 님이 기여했습니다. (#933, #1035)
  • enqueueMany()가 개별 전송을 수행하는 큐는 MessageQueue.atomicEnqueueMany를 false로 설정할 수 있습니다. 이렇게 하면 부분 전송 후 재시도 시 중복 인큐가 발생할 수 있으므로, Fedify는 단일 deduplicationKey로 제어되는 다중 메시지 배치를 사전에 거부합니다. (#930, #934)
  • Temporal을 사용하는 CommonJS 빌드는 런타임에 @js-temporal/polyfill을 요구하는 대신 temporal-polyfill을 번들링하며, 타입 선언은 표준 esnext.temporal 라이브러리에 의존합니다. (#823, #925)
  • 문서 로더의 Accept 헤더에 ActivityPub 및 FEP-ef61 게이트웨이의 요구 사항에 따라 JSON-LD 미디어 타입에 대한 ActivityStreams 프로파일이 포함됩니다. (#834, #1077)
  • 릴레이 문서가 이제 표준 액터 및 공유 인박스 URI를 사용하고, Mastodon 방식과 LitePub 방식의 구독을 구분하며, 애플리케이션의 책임으로 남는 부분을 설명합니다. 권지원 님이 기여했습니다. (#899, #996)

버그 수정

  • 인코딩된 값을 비교하는 키-값 저장소에서 아웃바운드 전송 서킷 브레이커 상태 전이 문제를 수정했습니다. 기존의 반열림(half-open) 상태는 CAS 지원 저장소로 전환한 후 복구될 수 있습니다. (#1163, #1167)
  • fedify lookup --recurse에서 타임아웃이나 기타 네트워크 오류가 발생했을 때 비공개 객체일 가능성이 있는 것으로 잘못 보고하던 문제를 수정했습니다. 이제 실제 원인을 보고합니다. (#1156, #1199)
  • fedify webfinger의 -p/--allow-private-address 옵션이 무시되던 문제를 수정했습니다. (#1156, #1199)
  • 중첩된 헬퍼가 객체의 다른 속성을 할당할 때 outbox-listener-delivery-required 및 outbox-listener-delivery-not-awaited가 해당 객체의 함수 추적을 놓치던 문제를 수정했습니다. (#1125, #1140)

업그레이드

npm add @fedify/fedify@2.4.0
pnpm add @fedify/fedify@2.4.0
yarn add @fedify/fedify@2.4.0
deno add jsr:@fedify/fedify@2.4.0
bun add @fedify/fedify@2.4.0

사용 중인 다른 @fedify/* 패키지도 동시에 2.4.0으로 업데이트하십시오. CLI를 업데이트하려면 npm install -g @fedify/cli@2.4.0을 실행하거나, 다른 패키지 매니저의 경우 설치 지침을 참조하십시오.

대부분의 애플리케이션은 코드 변경이 필요하지 않지만, 다음과 같은 동작 방식의 변화가 영향을 미칠 수 있습니다:

  • 액터의 DID로 생성된 유효한 객체 무결성 증명(Object Integrity Proof)이 없는 한, ID가 FEP-ef61 호환 식별자인 액터로부터 온 액티비티는 401 Unauthorized로 거부됩니다. 호환 식별자에 위치한 키와 이식 가능한 소유자를 주장하는 일반 URL의 키는 더 이상 오리진(origin)을 기준으로 신뢰하지 않습니다.
  • await create.getObject(ctx)와 같이 Context를 옵션으로 전달받은 속성 접근자는 일반 HTTP(S) ID를 가진 객체에서 호환 식별자를 참조하는 경우를 포함하여, 역참조하는 이식 가능한 객체를 이제 검증합니다.
  • /.well-known/apgateway/에 대한 요청으로 인해 디스패처가 호출될 수 있으며, Context.sendActivity()는 다른 주체의 이식 가능한 객체를 내포하는 액티비티를 거부할 수 있습니다. Next.js 애플리케이션은 해시링크 미디어를 제공하기 위해 위에 표시된 matcher가 필요합니다.
  • 요청의 RFC 9421 서명은 처음 3개까지만 검증됩니다. 더 많은 서명이 필요한 경우 maxHttpSignatures를 설정하십시오.
  • 기본 내장된 문서 로더는 10초 후 타임아웃 처리되며, fedify lookup 역시 마찬가지입니다.
  • 캐시된 공개키는 30일 후, HTTP Message Signatures 사양은 90일 후에 만료됩니다.
  • 툼스톤을 반환하는 Tombstone 또는 Object에 등록된 객체 디스패처는 이제 200 OK 대신 410 Gone으로 응답합니다.
  • PostgresKvStore는 초기화 시 기존의 언로그드 테이블(unlogged tables)을 로그 테이블(logged tables)로 마이그레이션하며, 이 과정에서 테이블 잠금이 발생합니다.
  • RedisKvStore는 cas()를 위해 EVAL 권한이 필요합니다.
  • 이전 버전의 큐 워커는 이식 가능한 인박스에 대해 큐에 대기 중인 액티비티를 첫 번째 게이트웨이를 통해서만 전송합니다. 전송을 큐에 넣는 서버보다 워커를 먼저 업그레이드하십시오.
  • Context 및 RequestContext 인터페이스의 커스텀 구현체에는 새로운 getPortable*Uri() 메서드와 isSignedByAudience()가 필요합니다.
  • fedify tunnel은 더 이상 localhost.run을 지원하지 않습니다.

감사의 말

이번 릴리스의 기여자분들께 감사드립니다:

  • Samuel Brinkmann (@sabrinkmann): @fedify/adonisjs, 그리고 FEP-7aa9 어휘집 요청
  • 장하나래 (@menele): SvelteKit 지원 및 fedify init의 test 태스크
  • 이정민 (@userjmmm): fedify init의 런타임 버전 검증
  • 장재혁 (@jhyuk): actor-preferred-username-required 및 outbox-listener-delivery-not-awaited 린트 규칙, 그리고 outbox-listener-delivery-required를 위한 제어 흐름 분석
  • archie (@ArchieTansaria): 아웃박스 전송 린트 규칙의 루프 처리
  • 홍재희 (@axz0612): 억제된 어휘집 실패에 대한 경고 수준 로깅
  • 채희원 (@heeoneie): 공개키 및 HTTP Message Signatures 사양 캐시 만료 처리

2.3.0 이후 이찬행(@2chanhaeng) 님과 권지원(@z9mb1) 님이 메인테이너로 합류했습니다. 이찬행 님은 백그라운드 작업 API와 @fedify/pglite를 구축했고, 권지원 님은 NetlifyBlobsKvStore를 구축하고 릴레이 문서를 재작성했으며, 두 분 모두 이번 릴리스의 나머지 부분 상당수를 리뷰했습니다.

이번 릴리스 주기 동안 많은 분들이 종종 good first issue를 시작으로 테스트, 문서, 인프라를 추가해 주셨습니다: 윤서아(@bananamilk452), 임규진(@dktsudgg), @m2nsp, 조성민(@seongmin36), Safal Shrestha(@Safal-Shrestha-SS), 조영재(@jaonz6057), @kecan0406, Loi Nguyen(@lntutor), 오태준(@0w0), 박예린(@dstforye), 이도건(@moreal), 김세진(@siliconsjang).

FEP-ef61 작업은 프로젝트 외부의 많은 분들 덕분에 가능했습니다. @anotherdoesnm 님이 #288에서 요청을 발의했습니다. FEP-ef61 및 FEP-ae97의 저자인 @silverpill@mitra.social은 게이트웨이 신뢰 모델을 명확히 하고, 복합 증명 프로파일을 검토하며, 해당 테스트 벡터를 확인했습니다. @dmitrizagidulin 님과 @by_caballero@mastodon.social은 #938에서 프로파일의 JSON-LD 관련 질문에 의견을 제시해 주었습니다. tootik 및 Mitra를 대상으로 한 테스트는 이번 릴리스의 상호 운용성 수정 사항 두 건으로 이어졌습니다.

전체 변경 로그는 CHANGES.md를 참조하십시오.

0 Boosts
0 Likes

No replies yet

Be the first to share your thoughts.