Linting
This package is available since Fedify 2.0.0.
TIP
We highly recommend using the @fedify/lint package in your federated server app to catch common mistakes early and enforce best practices.
Fedify provides the @fedify/lint package, which includes lint rules specifically designed for Fedify applications. It supports Deno Lint, ESLint, and Oxlint, so you can use it regardless of your JavaScript/TypeScript runtime.
The plugin includes rules that check for:
- Proper actor ID configuration
- Required actor properties (inbox, outbox, followers, etc.)
- Correct URL patterns for actor collections
- Public key and assertion method requirements
- Collection filtering implementation
Installation
deno add jsr:@fedify/lintnpm add -D @fedify/lintpnpm add -D @fedify/lintyarn add -D @fedify/lintbun add -D @fedify/lintDeno Lint
Basic setup
Add the plugin to your deno.json configuration file:
{
"lint": {
"plugins": ["jsr:@fedify/lint"]
}
}Listing the plugin enables every rule it provides, and Deno Lint reports all of them as errors. Plugin rules have no recommended subset and no severity levels: those concepts exist for Deno's own built-in rules, not for rules that come from a plugin.
Turning rules off
Rule IDs in deno.json are prefixed with the plugin's name, fedify-lint, which is what deno lint prints in its diagnostics. This differs from the ESLint and Oxlint plugins, where the prefix is the package name, @fedify/lint.
rules.exclude is the only setting that applies to plugin rules. List the rules you do not want, one ID at a time:
{
"lint": {
"plugins": ["jsr:@fedify/lint"],
"rules": {
"exclude": [
"fedify-lint/actor-featured-property-required",
"fedify-lint/actor-liked-property-required"
]
}
}
}rules.tags and rules.include select among Deno's built-in rules and leave plugin rules untouched, so neither can be used to enable a subset of this plugin. Excluding the plugin's name on its own does not work either; each rule has to be named.
Running Deno Lint
After setting up the configuration, run Deno's linter:
deno lintYou can also specify which files to lint:
deno lint federation.ts
deno lint src/federation/ESLint
Basic setup
Add the plugin to your ESLint configuration file (e.g., eslint.config.ts or eslint.config.js):
import fedifyLint from "@fedify/lint";
// If your `createFederation` code is in `federation.ts` or `federation/**.ts`
export default fedifyLint;Or specify your own federation files:
export default {
...fedifyLint,
files: ["my-own-federation.ts"],
};If you use other ESLint configurations:
export default [
// otherConfig,
fedifyLint,
];The default configuration applies recommended rules to files that match common federation-related patterns (e.g., federation.ts, federation/*.ts).
Custom configuration
You can customize which files to lint and which rules to enable:
import { plugin } from "@fedify/lint";
export default [{
files: ["src/federation/**/*.ts"], // Your federation code location
plugins: {
"@fedify/lint": plugin,
},
rules: {
"@fedify/lint/actor-id-required": "error",
"@fedify/lint/actor-id-mismatch": "error",
"@fedify/lint/actor-inbox-property-required": "warn",
// ... other rules
},
}];Using configurations
The plugin provides two preset configurations:
Recommended (default)
Enables critical rules as errors and optional rules as warnings:
import fedifyLint from "@fedify/lint";
export default fedifyLint;Strict
Enables all rules as errors:
import { plugin } from "@fedify/lint";
export default [{
files: ["**/*.ts"],
...plugin.configs.strict,
}];Running ESLint
Set up your ESLint configuration as shown above and add a script to package.json:
{
"scripts": {
"lint": "eslint ."
}
}After setting up the configuration, run ESLint on your codebase:
npm run lintpnpm lintyarn lintbun lintOr run the linter directly via command line:
npx eslint .pnpx eslint .yarn eslint .bunx eslint .Oxlint
Oxlint is a fast Rust-based linter that supports ESLint-compatible JS plugins. @fedify/lint exposes its rules through Oxlint's JS plugin API via the @fedify/lint/oxlint subpath export.
NOTE
Oxlint's JS plugin API is currently in alpha and not subject to semver.
Basic setup
Add the plugin to your .oxlintrc.json via the jsPlugins field, then enable the rules you want:
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"jsPlugins": ["@fedify/lint/oxlint"],
"rules": {
"@fedify/lint/actor-id-required": "error",
"@fedify/lint/actor-id-mismatch": "error"
}
}Rule IDs are namespaced under @fedify/lint/, matching the ESLint preset.
Custom configuration
Each rule accepts "error", "warn", or "off". Enable any subset listed in the Rules section below:
{
"jsPlugins": ["@fedify/lint/oxlint"],
"rules": {
"@fedify/lint/actor-id-required": "error",
"@fedify/lint/actor-id-mismatch": "error",
"@fedify/lint/actor-inbox-property-required": "warn",
"@fedify/lint/actor-outbox-property-required": "warn",
"@fedify/lint/actor-followers-property-required": "warn",
"@fedify/lint/actor-public-key-required": "warn",
"@fedify/lint/actor-assertion-method-required": "warn",
"@fedify/lint/collection-filtering-not-implemented": "warn"
}
}Running Oxlint
Add a script to package.json:
{
"scripts": {
"lint": "oxlint ."
}
}Then run the linter:
npm run lintpnpm lintyarn lintbun lintOr invoke Oxlint directly:
npx oxlint .pnpx oxlint .yarn oxlint .bunx oxlint .Rules
actor-id-required
Ensures all actors have an id property in the actor dispatcher.
When this rule applies: The actor dispatcher returns a Person, Organization, Group, Application, or Service object without an id property.
Why it matters: Every ActivityPub actor must have a unique identifier (ID) to be discoverable and to receive activities from other servers.
// ❌ Bad: Missing id property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
name: "John Doe", // No id!
});
});
// ✅ Good: Include id property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
});
});actor-id-mismatch
Validates that actor IDs match the expected URI from Context.getActorUri().
When this rule applies: The id property is set to a value other than ctx.getActorUri(identifier), such as a hardcoded URL string, new URL(...), or a different context method.
Why it matters: Using the wrong URI for the actor ID can cause federation issues. Other servers won't be able to properly verify the actor's identity or send activities to it.
// ❌ Bad: Using hardcoded URL
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: new URL(`https://example.com/users/${identifier}`),
name: "John Doe",
});
});
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getFollowersUri(identifier), // Wrong method!
name: "John Doe",
});
});
// ✅ Good: Use ctx.getActorUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
});
});actor-public-key-required
Ensures actors have public keys for HTTP Signatures.
When this rule applies: The actor dispatcher is chained with setKeyPairsDispatcher(), but the actor object doesn't include a publicKey or publicKeys property.
Why it matters: HTTP Signatures are used to verify the authenticity of activities. Without a public key, other servers cannot verify that activities sent by your actor are legitimate.
// ❌ Bad: Missing publicKey when setKeyPairsDispatcher is configured
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
// Missing publicKey!
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
// Returns key pairs...
return [];
});
// ✅ Good: Include publicKey from key pairs dispatcher
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const keyPairs = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
publicKey: keyPairs[0].cryptographicKey,
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
// Returns key pairs...
return [];
});actor-assertion-method-required
Validates that actors have assertion methods for Object Integrity Proofs.
When this rule applies: The actor dispatcher is chained with setKeyPairsDispatcher(), but the actor object doesn't include an assertionMethod property.
Why it matters: Object Integrity Proofs use assertion methods to cryptographically sign activities. This provides an additional layer of security beyond HTTP Signatures.
// ❌ Bad: Missing assertionMethod when setKeyPairsDispatcher is configured
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const keyPairs = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
publicKey: keyPairs[0].cryptographicKey,
// Missing assertionMethod!
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
// Returns key pairs...
return [];
});
// ✅ Good: Include assertionMethod from key pairs dispatcher
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const keyPairs = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
publicKey: keyPairs[0].cryptographicKey,
assertionMethod: keyPairs[0].multikey,
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
// Returns key pairs...
return [];
});actor-preferred-username-required
This rule is introduced in Fedify 2.4.0.
Ensures actors have a preferredUsername property.
When this rule applies: The actor dispatcher is configured with setActorDispatcher(), but the actor object doesn't include a preferredUsername property.
Why it matters: Most fediverse software expects actors to expose a stable preferredUsername. Omitting it tends to make remote display, search, and profile rendering worse, even though Fedify itself doesn't require it.
// ❌ Bad: Missing preferredUsername property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe", // No preferredUsername!
});
});
// ✅ Good: Include preferredUsername property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: "John Doe",
});
});actor-inbox-property-required
Ensures inbox is defined when setInboxListeners() is configured.
When this rule applies: You've called federation.setInboxListeners() to handle incoming activities, but the actor object doesn't include an inbox property.
Why it matters: The inbox URL tells other servers where to send activities to your actor. Without it, your actor cannot receive follow requests, mentions, or any other activities.
// ❌ Bad: Missing inbox when setInboxListeners is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
// Missing inbox!
});
});
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
// ✅ Good: Include inbox property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
name: "John Doe",
inbox: ctx.getInboxUri(identifier),
});
});
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");actor-inbox-property-mismatch
Validates that the inbox URI is set using ctx.getInboxUri(identifier).
When this rule applies: The inbox property is set to a value other than ctx.getInboxUri(identifier).
Why it matters: The inbox URI must match the path configured in setInboxListeners(). Using a different URI will cause incoming activities to fail.
// ❌ Bad: Using hardcoded URL
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: new URL(`https://example.com/inbox/${identifier}`), // Wrong!
});
});
// ✅ Good: Use ctx.getInboxUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: ctx.getInboxUri(identifier),
});
});actor-outbox-property-required
Ensures outbox is defined when setOutboxDispatcher() is configured.
When this rule applies: You've called federation.setOutboxDispatcher() to serve the actor's outbox, but the actor object doesn't include an outbox property.
Why it matters: The outbox URL allows other servers and users to view the actor's published activities. It's part of the standard ActivityPub actor profile.
// ❌ Bad: Missing outbox when setOutboxDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing outbox!
});
});
federation.setOutboxDispatcher(
"/users/{identifier}/outbox",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include outbox property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
outbox: ctx.getOutboxUri(identifier),
});
});actor-outbox-property-mismatch
Validates that the outbox URI is set using ctx.getOutboxUri(identifier).
When this rule applies: The outbox property is set to a value other than ctx.getOutboxUri(identifier).
Why it matters: The outbox URI must match the path configured in setOutboxDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
outbox: ctx.getInboxUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getOutboxUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
outbox: ctx.getOutboxUri(identifier),
});
});outbox-listener-delivery-required
Warns when an outbox listener body does not deliver the posted activity with ctx.sendActivity() or ctx.forwardActivity().
When this rule applies: You've registered an outbox listener with setOutboxListeners(), and the rule can show that no path through the listener body calls either delivery method. It follows the listener's own control flow (if/else, try/catch, switch, loops), so a delivery call that sits in a dead branch, after an unconditional return, or in a function that is never used does not count.
The rule reports only when it can account for every delivery call it can see and show that each one does not run. When it cannot tell, it stays quiet: a missed warning is the safe direction, while a warning on code that delivers is not. In practice:
- A function held under a name counts as used as soon as that name is mentioned anywhere in code that runs, however it is mentioned: called, passed to another function, aliased, destructured from an object, or reached through an array or a wrapper call. The rule does not follow the value any further, so a function that is only logged or stored, and never called, is not reported.
- An inline callback counts wherever it is passed, since the rule cannot show that the receiving call never runs it.
- The rule also follows calls to helpers declared in the same file when the listener passes its context to them. Helpers in another module are not followed.
- A function assigned to an enclosing object inside a local setup helper is not followed back to the caller. Even if
setup()installstarget.deliverbeforeawait target.deliver(), the rule may still report missing delivery. Define and call the delivery helper directly in the listener body to avoid this false report.
Why it matters: Fedify does not federate client-to-server outbox posts automatically. If your application intends to deliver a posted activity, the listener must choose an explicit delivery path, and that path must actually run.
The rule checks that a delivery call exists and can run, not that the delivery completes, so a listener it accepts is not guaranteed to federate. A delivery call that is never awaited is not reported here; the outbox-listener-delivery-not-awaited rule checks for that.
// ❌ Bad: Listener stores the activity locally but never federates it
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
console.log(ctx.identifier, activity.id?.href);
});
// ❌ Bad: The delivery call is unreachable dead code
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
if (activity.id == null) return;
return;
await ctx.sendActivity(
{ identifier: ctx.identifier },
"followers",
activity,
);
});
// ✅ Good: Listener federates explicitly
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
await ctx.sendActivity(
{ identifier: ctx.identifier },
"followers",
activity,
);
});
// ✅ Good: Listener forwards the original posted payload explicitly
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx) => {
await ctx.forwardActivity(
{ identifier: ctx.identifier },
"followers",
);
});
// ✅ Good: Delivery happens inside a helper that is actually called
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
async function deliver() {
await ctx.sendActivity(
{ identifier: ctx.identifier },
"followers",
activity,
);
}
await deliver();
});
// ✅ Good: A helper reached through a destructured property still counts
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
const handlers = {
deliver: () =>
ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity),
};
const { deliver } = handlers;
await deliver();
});
// ✅ Good: A called helper in the same file also counts
async function deliverToFollowers(ctx: OutboxContext<void>, activity: Activity) {
await ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity);
}
federation.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
await deliverToFollowers(ctx, activity);
});The rule follows direct calls to functions, function bindings, and object-literal methods declared in the same file when the listener's context is passed as an argument. Helpers may call other helpers; recursive calls do not cause the analysis to loop. Declaring a module-level delivery helper without calling it does not satisfy the rule.
A directly called local setup helper can replace a method on an enclosing object with a delivery function before the listener calls that method. The rule checks that the setup call comes first and that both references name the same object. A helper that is not called, or that runs after the method call, does not count as delivery.
This analysis does not follow imports or use type information. A listener that only calls a helper imported from another file still receives a warning. Indirect calls through higher-order callbacks, class instances, and dynamically selected properties are not resolved.
outbox-listener-delivery-not-awaited
Warns when an outbox listener calls ctx.sendActivity() or ctx.forwardActivity() and lets the returned promise go without waiting for it.
INFO
This rule is available in ESLint and Oxlint, but not in Deno Lint: Deno turns on every rule of a plugin as soon as the plugin is listed, and gives a project no way to keep one off until it asks for it. In ESLint, the recommended configuration enables it as a warning and strict as an error. In Oxlint, enable it by name.
When this rule applies: You've registered an outbox listener with setOutboxListeners(), and it calls ctx.sendActivity() or ctx.forwardActivity() in a way that drops the returned promise. The rule follows the promise from the call to where it ends up. A call counts as handled when its promise is awaited, returned, passed to Promise.all() or Promise.allSettled(), or handed to a method named waitUntil(). A call is reported when its promise is discarded, including when it is only kept in a variable that nothing else uses.
When it cannot tell where a promise goes, the rule stays quiet. In practice:
void ctx.sendActivity(...),Promise.race(...)andPromise.any(...)are read as deliberate choices to stop waiting, and are not reported. Arace()orany()whose own result is dropped is still reported, and so is any other operator applied to a promise, such as!ortypeof. So is a promise used as the test of anifstatement, a loop or a conditional expression, since a promise is always truthy and testing it waits for nothing. A discarded.catch(),.then()or.finally()chain is reported, since a.catch()handles the error but does not wait.- An array of promises, such as the result of
map(), waits for nothing on its own, and neither does an object that holds a promise, such as{ pending: ctx.sendActivity(...) }. Awaiting one, or returning it from the listener, is reported. An array counts as handled once it reachesPromise.all()or one of its siblings, and either one counts as handled when it is kept in a variable that is mentioned again. - An
asynccallback that awaits a delivery is judged by where the callback goes, since its own promise is what carries the delivery.forEach()drops that promise, soinboxes.forEach(async (inbox) => { await ... })is reported. A callback that is invoked immediately, or given tomap()orthen(), is only as safe as the result of that call. The same holds for a function passed by name, as ininboxes.forEach(deliver). - A local helper that delivers is judged by how it is called:
deliver();is reported whendeliver()awaits or returns a delivery, andawait deliver();is not. An array of promises, or an object holding one, that a helper returns is not followed to where the helper is called, since the caller may pass it toPromise.all(). Soawait deliverAll();and a baredeliverAll();are not reported whendeliverAll()returns the result ofmap(). - A promise passed to a function the rule does not know, such as
queue.push(...)orsetTimeout(...), is left alone, since the rule cannot tell what that function does with it. - The rule leans towards quiet where a name is only mentioned. A stored promise counts as used when its variable is mentioned anywhere in the listener, even only in a dead branch or in a helper that is never called. A helper that delivers counts as running once its name is mentioned, even if it is only stored or logged, so a bare delivery call inside it is still reported.
- As in
outbox-listener-delivery-required, the rule reads only the listener body. A delivery call in a helper that is declared outside the listener, or in another module, is not seen. - A function assigned to an enclosing object inside a local setup helper is not followed back to the caller. A dropped delivery promise inside that function may go unreported, even if the caller awaits the installed function. Define and call the delivery helper directly in the listener body so that the rule can check it.
Why it matters:ctx.sendActivity() returns a promise. A listener that calls it without waiting hands that promise to nobody, and the handler can return while delivery is still in flight. On a long-lived Node.js or Deno process this usually works out. On Cloudflare Workers, which Fedify supports through @fedify/cfworkers, pending work is dropped once the response is returned, so the activity may never leave. Every sendActivity() example in Sending activities awaits the call.
This rule is separate from outbox-listener-delivery-required, which asks whether a delivery call exists and can run, not whether anything waits for it.
// ❌ Bad: The promise is dropped, so the activity can be lost
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity);
});
// ❌ Bad: forEach() discards what its callback returns
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
recipients.forEach((recipient) =>
ctx.sendActivity({ identifier: ctx.identifier }, recipient, activity)
);
});
// ✅ Good: The delivery is awaited
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
await ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity);
});
// ✅ Good: Every delivery is awaited together
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
await Promise.all(
recipients.map((recipient) =>
ctx.sendActivity({ identifier: ctx.identifier }, recipient, activity)
),
);
});
// ✅ Good: The runtime keeps the work alive after the response is returned
federation
.setOutboxListeners("/users/{identifier}/outbox")
.on(Activity, async (ctx, activity) => {
executionCtx.waitUntil(
ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity),
);
});media-uploader-object-uri-required
Warns when a setMediaUploader() callback returns a value that is not derived from ctx.getObjectUri().
When this rule applies: You've registered a media uploader with setMediaUploader(), but its callback never references ctx.getObjectUri(), so the returned id/URL probably does not point at a registered object dispatcher route.
Why it matters: The upload endpoint emits object IDs, but serving those IDs back as fetchable ActivityStreams objects is the developer's job via setObjectDispatcher(). Deriving the returned value from ctx.getObjectUri() keeps the emitted ID in sync with a registered object dispatcher route.
// ❌ Bad: Returned id is a hard-coded URL, not from ctx.getObjectUri()
federation.setMediaUploader(
"/users/{identifier}/media",
async (ctx, identifier, file, object) => {
return new Image({ id: new URL("https://example.com/media/1") });
},
);
// ✅ Good: Returned id is derived from ctx.getObjectUri()
federation.setMediaUploader(
"/users/{identifier}/media",
async (ctx, identifier, file, object) => {
return new Image({
id: ctx.getObjectUri(Image, { uuid: "1" }),
mediaType: file.type,
});
},
);media-uploader-authorization-required
Warns when setMediaUploader() is registered without an .authorize() hook.
When this rule applies: You've called federation.setMediaUploader() but never chained (or otherwise called) .authorize() on the returned setter.
Why it matters: Without an authorize hook, the upload endpoint accepts uploads from anyone who can reach the URL. For an endpoint that stores files, that is a serious abuse, denial-of-service, and storage-cost risk, so .authorize() should be configured unless a public upload endpoint is genuinely intended.
// ❌ Bad: No authorize() hook, so anyone can upload
federation.setMediaUploader(
"/users/{identifier}/media",
async (ctx, identifier, file, object) =>
ctx.getObjectUri(Image, { uuid: "1" }),
);
// ✅ Good: Protected with authorize()
federation
.setMediaUploader(
"/users/{identifier}/media",
async (ctx, identifier, file, object) =>
ctx.getObjectUri(Image, { uuid: "1" }),
)
.authorize(async (ctx, identifier) => {
return ctx.request.headers.get("authorization") === `Bearer ${identifier}`;
});actor-followers-property-required
Ensures followers is defined when setFollowersDispatcher() is configured.
When this rule applies: You've called federation.setFollowersDispatcher() to serve the actor's followers collection, but the actor object doesn't include a followers property.
Why it matters: The followers URL allows other servers to discover who follows this actor, which is important for activity delivery and social graph discovery.
// ❌ Bad: Missing followers when setFollowersDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing followers!
});
});
federation.setFollowersDispatcher(
"/users/{identifier}/followers",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include followers property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
followers: ctx.getFollowersUri(identifier),
});
});actor-followers-property-mismatch
Validates that the followers URI is set using ctx.getFollowersUri(identifier).
When this rule applies: The followers property is set to a value other than ctx.getFollowersUri(identifier).
Why it matters: The followers URI must match the path configured in setFollowersDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
followers: ctx.getFollowingUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getFollowersUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
followers: ctx.getFollowersUri(identifier),
});
});actor-following-property-required
Ensures following is defined when setFollowingDispatcher() is configured.
When this rule applies: You've called federation.setFollowingDispatcher() to serve the actor's following collection, but the actor object doesn't include a following property.
Why it matters: The following URL allows other servers to discover who this actor follows.
// ❌ Bad: Missing following when setFollowingDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing following!
});
});
federation.setFollowingDispatcher(
"/users/{identifier}/following",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include following property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
following: ctx.getFollowingUri(identifier),
});
});actor-following-property-mismatch
Validates that the following URI is set using ctx.getFollowingUri(identifier).
When this rule applies: The following property is set to a value other than ctx.getFollowingUri(identifier).
Why it matters: The following URI must match the path configured in setFollowingDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
following: ctx.getFollowersUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getFollowingUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
following: ctx.getFollowingUri(identifier),
});
});actor-liked-property-required
Ensures liked is defined when setLikedDispatcher() is configured.
When this rule applies: You've called federation.setLikedDispatcher() to serve the actor's liked collection, but the actor object doesn't include a liked property.
Why it matters: The liked URL allows other servers to discover what content this actor has liked.
// ❌ Bad: Missing liked when setLikedDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing liked!
});
});
federation.setLikedDispatcher(
"/users/{identifier}/liked",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include liked property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
liked: ctx.getLikedUri(identifier),
});
});actor-liked-property-mismatch
Validates that the liked URI is set using ctx.getLikedUri(identifier).
When this rule applies: The liked property is set to a value other than ctx.getLikedUri(identifier).
Why it matters: The liked URI must match the path configured in setLikedDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
liked: ctx.getFollowersUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getLikedUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
liked: ctx.getLikedUri(identifier),
});
});actor-featured-property-required
Ensures featured is defined when setFeaturedDispatcher() is configured.
When this rule applies: You've called federation.setFeaturedDispatcher() to serve the actor's featured/pinned posts collection, but the actor object doesn't include a featured property.
Why it matters: The featured URL allows other servers to discover the actor's pinned or highlighted content (commonly shown at the top of a profile).
// ❌ Bad: Missing featured when setFeaturedDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing featured!
});
});
federation.setFeaturedDispatcher(
"/users/{identifier}/featured",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include featured property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featured: ctx.getFeaturedUri(identifier),
});
});actor-featured-property-mismatch
Validates that the featured URI is set using ctx.getFeaturedUri(identifier).
When this rule applies: The featured property is set to a value other than ctx.getFeaturedUri(identifier).
Why it matters: The featured URI must match the path configured in setFeaturedDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featured: ctx.getFollowersUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getFeaturedUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featured: ctx.getFeaturedUri(identifier),
});
});actor-featured-tags-property-required
Ensures featuredTags is defined when setFeaturedTagsDispatcher() is configured.
When this rule applies: You've called federation.setFeaturedTagsDispatcher() to serve the actor's featured hashtags collection, but the actor object doesn't include a featuredTags property.
Why it matters: The featuredTags URL allows other servers to discover the actor's featured hashtags (commonly used for profile discovery).
// ❌ Bad: Missing featuredTags when setFeaturedTagsDispatcher is configured
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing featuredTags!
});
});
federation.setFeaturedTagsDispatcher(
"/users/{identifier}/tags",
(ctx, identifier) => ({ items: [] })
);
// ✅ Good: Include featuredTags property
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featuredTags: ctx.getFeaturedTagsUri(identifier),
});
});actor-featured-tags-property-mismatch
Validates that the featuredTags URI is set using ctx.getFeaturedTagsUri(identifier).
When this rule applies: The featuredTags property is set to a value other than ctx.getFeaturedTagsUri(identifier).
Why it matters: The featuredTags URI must match the path configured in setFeaturedTagsDispatcher().
// ❌ Bad: Using wrong context method
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featuredTags: ctx.getFollowersUri(identifier), // Wrong method!
});
});
// ✅ Good: Use ctx.getFeaturedTagsUri()
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
featuredTags: ctx.getFeaturedTagsUri(identifier),
});
});actor-shared-inbox-property-required
Ensures endpoints.sharedInbox is defined when setInboxListeners() is configured with a shared inbox path.
When this rule applies: You've called federation.setInboxListeners() with a second parameter (shared inbox path), but the actor object doesn't include an endpoints: new Endpoints({ sharedInbox: ... }) property.
Why it matters: The shared inbox allows other servers to send activities to multiple actors on your server with a single request, improving federation efficiency.
// ❌ Bad: Missing sharedInbox when setInboxListeners has shared inbox path
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: ctx.getInboxUri(identifier),
// Missing endpoints.sharedInbox!
});
});
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
// ✅ Good: Include endpoints.sharedInbox
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
}),
});
});actor-shared-inbox-property-mismatch
Validates that endpoints.sharedInbox is set using ctx.getInboxUri() (without identifier).
When this rule applies: The endpoints.sharedInbox property is set to a value other than ctx.getInboxUri() (called without arguments for the shared inbox).
Why it matters: The shared inbox URI must match the shared inbox path configured in setInboxListeners().
// ❌ Bad: Using getInboxUri with identifier for shared inbox
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(identifier), // Wrong! Should be no args
}),
});
});
// ✅ Good: Use ctx.getInboxUri() without arguments
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(), // No identifier for shared inbox
}),
});
});actor-upload-media-property-required
Ensures endpoints.uploadMedia is defined when setMediaUploader() is configured.
When this rule applies: You've called federation.setMediaUploader(), but the actor object doesn't advertise the endpoint under an endpoints: new Endpoints({ uploadMedia: ... }) property.
Why it matters: Registering a media uploader does not by itself expose the endpoint to clients. Advertising it under endpoints.uploadMedia is what lets clients discover where to upload media.
// ❌ Bad: Missing endpoints.uploadMedia when a media uploader is registered
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
// Missing endpoints.uploadMedia!
});
});
federation.setMediaUploader(
"/users/{identifier}/media",
async (ctx, identifier, file, object) =>
ctx.getObjectUri(Person, { uuid: "1" }),
);
// ✅ Good: Advertise endpoints.uploadMedia
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
endpoints: new Endpoints({
uploadMedia: ctx.getMediaUploaderUri(identifier),
}),
});
});actor-upload-media-property-mismatch
Validates that endpoints.uploadMedia is set using ctx.getMediaUploaderUri(identifier).
When this rule applies: The endpoints.uploadMedia property is set to a value other than ctx.getMediaUploaderUri(identifier).
Why it matters: The advertised upload endpoint URI must match the path configured in setMediaUploader(), or clients will upload to the wrong URL.
// ❌ Bad: Using a hard-coded URL for the upload endpoint
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
endpoints: new Endpoints({
uploadMedia: new URL("https://example.com/upload"), // Wrong!
}),
});
});
// ✅ Good: Use ctx.getMediaUploaderUri(identifier)
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
endpoints: new Endpoints({
uploadMedia: ctx.getMediaUploaderUri(identifier),
}),
});
});collection-filtering-not-implemented
Warns when collection dispatchers don't implement filtering.
When this rule applies: The setFollowersDispatcher() callback function has fewer than 4 parameters (missing the filter parameter).
NOTE
Currently, this rule only checks setFollowersDispatcher(). Other collection dispatchers may be added in the future.
Why it matters: Collection filtering allows clients to request specific subsets of a collection, reducing response payload sizes and improving performance. Without filtering, large collections could cause performance issues.
For more information, see the Filtering by server section in the collections manual.
// ❌ Bad: Missing filter parameter
federation.setFollowersDispatcher(
"/users/{identifier}/followers",
async (ctx, identifier, cursor) => { // Only 3 parameters!
return { items: [] };
}
);
// ✅ Good: Include filter parameter (4th parameter)
federation.setFollowersDispatcher(
"/users/{identifier}/followers",
async (ctx, identifier, cursor, filter) => {
// Use filter to handle filtering requests
return { items: [] };
}
);Example
Here's an example of code that would trigger lint errors:
// ❌ Wrong: Using relative URL for actor ID
federation.setActorDispatcher(
"/{identifier}",
(_ctx, identifier) => {
return new Person({
id: new URL(`/${identifier}`), // ❌ Should use ctx.getActorUri()
name: "Example User",
});
},
);Corrected version:
// ✅ Correct: Using Context.getActorUri() for actor ID
federation.setActorDispatcher(
"/{identifier}",
(ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier), // ✅ Correct
name: "Example User",
inbox: ctx.getInboxUri(identifier),
outbox: ctx.getOutboxUri(identifier),
followers: ctx.getFollowersUri(identifier),
// ... other required properties
});
},
);When you run the linter on the incorrect code, you'll see an error like:
error[fedify-lint/actor-id-mismatch]: Actor's `id` property must match
`ctx.getActorUri(identifier)`. Ensure you're using the correct context method.