diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4bee97f..21fc6ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -64,6 +64,9 @@ The workspace is defined by *pnpm-workspace.yaml*; packages live under the
- *packages/drfed* is the main application package. It exports the
`drfed-server` binary from *bin/drfed-server.mjs*.
+ - *packages/federation* registers the ActivityPub dispatchers and inbox
+ listeners with Fedify, serializes stored objects and activities, and
+ owns the instance host rules.
- *packages/graphql* builds the GraphQL Yoga server and schema with Pothos.
- *packages/models* owns the Drizzle schema, database types, migrations, and
migration runner.
@@ -72,19 +75,22 @@ The workspace is defined by *pnpm-workspace.yaml*; packages live under the
- *packages/models/drizzle* contains generated Drizzle migration files.
Keep package boundaries clear. Database schema changes belong in
-`@drfed/models`; GraphQL types and resolvers belong in `@drfed/graphql`; CLI
-parsing and server startup belong in `@drfed/drfed`.
+`@drfed/models`; ActivityPub handlers and vocabulary serialization belong in
+`@drfed/federation`; GraphQL types and resolvers belong in `@drfed/graphql`;
+CLI parsing and server startup belong in `@drfed/drfed`. `@drfed/federation`
+must not depend on `@drfed/graphql`.
Packages
--------
-| Package | npm name | Description |
-| ------------------ | ---------------- | ----------------------------------------------- |
-| *packages/drfed* | `@drfed/drfed` | CLI binary, server startup, and HTTP serving |
-| *packages/graphql* | `@drfed/graphql` | GraphQL schema and Yoga server (Pothos + Relay) |
-| *packages/models* | `@drfed/models` | Drizzle schema, relations, and migration runner |
-| *packages/web* | `@drfed/web` | SolidStart frontend web app. |
+| Package | npm name | Description |
+| --------------------- | ------------------- | ----------------------------------------------- |
+| *packages/drfed* | `@drfed/drfed` | CLI binary, server startup, and HTTP serving |
+| *packages/federation* | `@drfed/federation` | ActivityPub dispatchers, listeners, and origins |
+| *packages/graphql* | `@drfed/graphql` | GraphQL schema and Yoga server (Pothos + Relay) |
+| *packages/models* | `@drfed/models` | Drizzle schema, relations, and migration runner |
+| *packages/web* | `@drfed/web` | SolidStart frontend web app. |
Each package has its own *README.md* with a more detailed breakdown.
@@ -346,6 +352,38 @@ Review generated SQL before committing it. Drizzle migration files under
installed users.
+Federation changes
+------------------
+
+The ActivityPub layer lives in *packages/federation*.
+
+ - *src/index.ts* exports `buildFederation()`, which creates a fresh Fedify
+ builder and registers every dispatcher and listener on it, and
+ `createFederation()`, which builds it with the given Fedify options.
+ - *src/actor.ts*, *src/object-dispatchers.ts*, *src/collection.ts*, and
+ *src/inbox.ts* each register one group of handlers on the builder they
+ are given. Do not register anything on a module-level builder.
+ - *src/object.ts* holds the query selections and serializers for stored
+ objects and activities. GraphQL mutations use the same serializers to
+ build the JSON-LD documents they store, so that stored documents and
+ ActivityPub responses never diverge.
+ - *src/origin.ts* holds the instance host rules; see below.
+
+Activity delivery observations live in `activity_deliveries`, independently
+of the ActivityPub `activities` resources, and *src/activity-delivery/* holds
+the code that records them. The federation HTTP surface must pass through
+`createInboundRecorder` with a federation made by `createFederation`, which
+tracks the public keys, spans and measurements Fedify reports for each
+request, and the deployment's root origin. Keep the public-key cache
+serialization compatible with the installed Fedify version,
+and read only the spans, events and metrics Fedify documents in its
+OpenTelemetry manual; never verify a request again. Inbox listeners must call
+`markHandled()`, which is how a delivery tells a received activity from an
+acknowledged one. Use `deliverActivity` for outgoing delivery, and create the
+federation through `createFederation`, which observes the outbox queue so that
+each attempt Fedify's worker makes settles its delivery.
+
+
GraphQL changes
---------------
@@ -362,20 +400,9 @@ When adding a new object or field, follow the existing `builder.drizzleNode()`
and `t.drizzleField()` patterns. Keep resolver database access through
`ctx.db`.
-Activity delivery observations live in `activity_deliveries`, independently
-of the ActivityPub `activities` resources. The federation HTTP surface must
-pass through `createInboundRecorder` with a federation made by
-`createFederation`, which tracks the public keys, spans and measurements Fedify
-reports for each request, and the deployment's root origin. Keep the
-public-key cache serialization compatible with the installed Fedify version,
-and read only the spans, events and metrics Fedify documents in its
-OpenTelemetry manual; never verify a request again. Inbox listeners must call
-`markHandled()`, which is how a delivery tells a received activity from an
-acknowledged one. Use `deliverActivity` for outgoing delivery, and create the
-federation through `createFederation`, which observes the outbox queue so that
-each attempt Fedify's worker makes settles its delivery. Delivery contents are
-private to local instance members and administrators, including Relay node
-lookups.
+The GraphQL types for activity deliveries live in
+*src/activity-delivery/entry.ts*. Delivery contents are private to local
+instance members and administrators, including Relay node lookups.
CLI and server changes
@@ -410,8 +437,8 @@ Requests are routed by the authority they arrive on, in
is an instance and serves ActivityPub only; the root origin and every other
authority serve GraphQL and never answer as an instance; anything deeper under
the root domain is answered 421, and an unusable `Host` header 400. The
-classification itself lives in *packages/graphql/src/origin.ts* alongside the
-functions that compose an instance's authority, so that the two can never
+classification itself lives in *packages/federation/src/origin.ts* alongside
+the functions that compose an instance's authority, so that the two can never
disagree about what an instance host looks like. Anything that changes how a
host is composed or compared belongs there, not in the server.
diff --git a/mise.toml b/mise.toml
index 4a67d40..abb63a3 100644
--- a/mise.toml
+++ b/mise.toml
@@ -27,7 +27,7 @@ silent = "stdout"
[tasks."build:server"]
description = "Build the project except web"
-run = "pnpm --parallel -F @drfed/graphql -F @drfed/models -F @drfed/drfed build"
+run = "pnpm --parallel -F @drfed/federation -F @drfed/graphql -F @drfed/models -F @drfed/drfed build"
silent = "stdout"
[tasks."build:web"]
diff --git a/packages/drfed/README.md b/packages/drfed/README.md
index b3b68bc..0f91676 100644
--- a/packages/drfed/README.md
+++ b/packages/drfed/README.md
@@ -2,8 +2,9 @@
============
The main application package for [DrFed], a web-based platform for developing
-and debugging ActivityPub apps. It wires together the database layer, GraphQL
-server, and HTTP server, and exposes the `drfed-server` CLI binary.
+and debugging ActivityPub apps. It wires together the database layer,
+ActivityPub federation, GraphQL server, and HTTP server, and exposes the
+`drfed-server` CLI binary.
[DrFed]: https://drfed.org/
diff --git a/packages/drfed/package.json b/packages/drfed/package.json
index 33442e6..3be46b9 100644
--- a/packages/drfed/package.json
+++ b/packages/drfed/package.json
@@ -86,6 +86,7 @@
"typescript": "catalog:"
},
"dependencies": {
+ "@drfed/federation": "workspace:*",
"@drfed/graphql": "workspace:*",
"@drfed/models": "workspace:*",
"@electric-sql/pglite": "catalog:",
diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index 8a7d90e..75ca17a 100644
--- a/packages/drfed/src/index.ts
+++ b/packages/drfed/src/index.ts
@@ -17,10 +17,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { writeFile } from "node:fs/promises";
import process from "node:process";
+import createFederation, { createInboundRecorder } from "@drfed/federation";
import { createYogaServer } from "@drfed/graphql";
-import createFederation, {
- createInboundRecorder,
-} from "@drfed/graphql/federation";
import { schema } from "@drfed/graphql/schema";
import { migrate } from "@drfed/models";
import { PgliteKvStore } from "@fedify/pglite";
diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts
index d581ad1..2e1933e 100644
--- a/packages/drfed/src/serving.test.ts
+++ b/packages/drfed/src/serving.test.ts
@@ -21,9 +21,7 @@ import {
findStrandedInstances,
warnAboutStrandedInstances,
} from "@drfed/drfed/serving";
-import createFederation, {
- createInboundRecorder,
-} from "@drfed/graphql/federation";
+import createFederation, { createInboundRecorder } from "@drfed/federation";
import { migrate, relations, schema } from "@drfed/models";
import { uuidV7 as uuid } from "@drfed/models/uuid";
import { PGlite } from "@electric-sql/pglite";
diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts
index 5f7c13c..3c76bde 100644
--- a/packages/drfed/src/serving.ts
+++ b/packages/drfed/src/serving.ts
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { canonicalizeAuthority, classifyHost } from "@drfed/graphql/origin";
+import { canonicalizeAuthority, classifyHost } from "@drfed/federation/origin";
import type { Database } from "@drfed/models";
import { getLogger } from "@logtape/logtape";
diff --git a/packages/federation/README.md b/packages/federation/README.md
new file mode 100644
index 0000000..17721f3
--- /dev/null
+++ b/packages/federation/README.md
@@ -0,0 +1,67 @@
+@drfed/federation
+=================
+
+ActivityPub federation for [DrFed], built with [Fedify]. Registers the
+dispatchers and inbox listeners that serve local actors, objects, activities,
+and collections, serializes stored objects and activities into ActivityPub
+vocabulary, and owns the rules for composing and recognizing instance hosts.
+
+This package depends on `@drfed/models` but not on `@drfed/graphql`, so
+serving ActivityPub never requires a GraphQL schema.
+
+[DrFed]: https://drfed.org/
+[Fedify]: https://fedify.dev/
+
+
+Usage
+-----
+
+~~~~ ts
+import createFederation from "@drfed/federation";
+
+const federation = await createFederation(db, { kv });
+~~~~
+
+`createFederation()` creates a fresh builder with every DrFed dispatcher and
+listener registered on it, then builds it with the given Fedify options. Each
+call returns an independent `Federation` that resolves local actors from the
+given database. `buildFederation(db)` returns the builder without building
+it, for callers that build it themselves.
+
+
+Exports
+-------
+
+| Import | Contents |
+| ------------------------------------- | ------------------------------------------------------------ |
+| `@drfed/federation` | `createFederation()` (default), `buildFederation()` |
+| `@drfed/federation/activity-delivery` | Inbound recording, outbound delivery, and their observations |
+| `@drfed/federation/object` | Query selections, `toObject()`, and `toCreate()` |
+| `@drfed/federation/origin` | `instanceHost()`, `classifyHost()`, and other host helpers |
+
+The serializers in `@drfed/federation/object` are the same ones the
+dispatchers use, so documents stored by GraphQL mutations match what is served
+over ActivityPub. Load rows with the matching selection before serializing
+them.
+
+
+Activity deliveries
+-------------------
+
+`createFederation()` tracks the public keys, spans, and measurements Fedify
+reports, and observes the outbox queue when one is given, so that each delivery
+can be recorded in `activity_deliveries`. Wrap the federation's HTTP surface
+with `createInboundRecorder()` to record every inbox request, and send
+activities with `deliverActivity()`. Both are also exported from the package
+root. The GraphQL fields that read these records are documented in
+[`@drfed/graphql`].
+
+[`@drfed/graphql`]: https://github.com/fedify-dev/drfed/tree/main/packages/graphql
+
+
+Logging
+-------
+
+Inbox activity is logged under the `["drfed", "federation"]` category, and
+activity delivery recording under
+`["drfed", "federation", "activity-delivery"]`.
diff --git a/packages/federation/package.json b/packages/federation/package.json
new file mode 100644
index 0000000..9ab59d7
--- /dev/null
+++ b/packages/federation/package.json
@@ -0,0 +1,98 @@
+{
+ "name": "@drfed/federation",
+ "version": "0.1.0",
+ "description": "ActivityPub federation for DrFed.",
+ "keywords": [
+ "ActivityPub",
+ "fediverse",
+ "federation",
+ "debugger"
+ ],
+ "author": {
+ "name": "DrFed team",
+ "url": "https://drfed.org/"
+ },
+ "maintainers": [
+ {
+ "name": "ChanHaeng Lee",
+ "email": "2chanhaeng@gmail.com",
+ "url": "https://chomu.dev/"
+ },
+ {
+ "name": "Hong Minhee",
+ "email": "hong@minhee.org",
+ "url": "https://hongminhee.org/"
+ },
+ {
+ "name": "Hyeonseo Kim",
+ "email": "dodok8@gmail.com",
+ "url": "https://hackers.pub/@gaebalgom"
+ },
+ {
+ "name": "Jiwon Kwon",
+ "email": "work@kwonjiwon.org",
+ "url": "https://kwonjiwon.org/"
+ }
+ ],
+ "license": "AGPL-3.0-only",
+ "engines": {
+ "node": ">=26.0.0"
+ },
+ "type": "module",
+ "main": "dist/index.mjs",
+ "types": "dist/index.d.mts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "./activity-delivery": {
+ "types": "./dist/activity-delivery.d.mts",
+ "default": "./dist/activity-delivery.mjs"
+ },
+ "./object": {
+ "types": "./dist/object.d.mts",
+ "default": "./dist/object.mjs"
+ },
+ "./origin": {
+ "types": "./dist/origin.d.mts",
+ "default": "./dist/origin.mjs"
+ }
+ },
+ "files": [
+ "dist/",
+ "README.md"
+ ],
+ "tsdown": {
+ "entry": [
+ "src/index.ts",
+ "src/activity-delivery.ts",
+ "src/object.ts",
+ "src/origin.ts"
+ ],
+ "dts": {
+ "sourcemap": true,
+ "tsconfig": "../../tsconfig.federation.json"
+ },
+ "sourcemap": true
+ },
+ "scripts": {
+ "build": "tsdown",
+ "test": "node --test"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "catalog:",
+ "@logtape/testing-node": "catalog:",
+ "@types/node": "catalog:",
+ "tsdown": "catalog:",
+ "typescript": "catalog:"
+ },
+ "dependencies": {
+ "@drfed/models": "workspace:*",
+ "@fedify/fedify": "catalog:",
+ "@fedify/vocab": "catalog:",
+ "@logtape/logtape": "catalog:",
+ "@opentelemetry/api": "^1.9.1",
+ "drizzle-orm": "catalog:"
+ }
+}
diff --git a/packages/federation/src/activity-delivery.ts b/packages/federation/src/activity-delivery.ts
new file mode 100644
index 0000000..98ff892
--- /dev/null
+++ b/packages/federation/src/activity-delivery.ts
@@ -0,0 +1,40 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+export { createKeyCache } from "./activity-delivery/keycache.ts";
+export {
+ classifyInbound,
+ createInboundRecorder,
+ parseBody,
+ recordedHeaders,
+} from "./activity-delivery/inbound.ts";
+export { describeActivity } from "./activity-delivery/describe.ts";
+export {
+ declaredKeyId,
+ hasLdSignature,
+ proofMethods,
+ reportedVerdict,
+} from "./activity-delivery/verification.ts";
+export {
+ deliverActivity,
+ groupRecipients,
+} from "./activity-delivery/outbound.ts";
+export { failureOf, queuedSettlements } from "./activity-delivery/queue.ts";
+export type {
+ ObservedKeyFetch,
+ ObservedSpan,
+ TrackedFederation,
+} from "./activity-delivery/tracking.ts";
diff --git a/packages/graphql/src/activity-delivery/addressing.ts b/packages/federation/src/activity-delivery/addressing.ts
similarity index 100%
rename from packages/graphql/src/activity-delivery/addressing.ts
rename to packages/federation/src/activity-delivery/addressing.ts
diff --git a/packages/graphql/src/activity-delivery/describe.ts b/packages/federation/src/activity-delivery/describe.ts
similarity index 100%
rename from packages/graphql/src/activity-delivery/describe.ts
rename to packages/federation/src/activity-delivery/describe.ts
diff --git a/packages/graphql/src/activity-delivery/inbound.ts b/packages/federation/src/activity-delivery/inbound.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/inbound.ts
rename to packages/federation/src/activity-delivery/inbound.ts
index e124780..19a18e7 100644
--- a/packages/graphql/src/activity-delivery/inbound.ts
+++ b/packages/federation/src/activity-delivery/inbound.ts
@@ -38,7 +38,7 @@ import {
} from "./tracking.ts";
import { observeVerification } from "./verification.ts";
-const logger = getLogger(["drfed", "graphql", "activity-delivery"]);
+const logger = getLogger(["drfed", "federation", "activity-delivery"]);
type InboundStatus = "received" | "acknowledged" | "unverified" | "rejected";
diff --git a/packages/graphql/src/activity-delivery/keycache.ts b/packages/federation/src/activity-delivery/keycache.ts
similarity index 100%
rename from packages/graphql/src/activity-delivery/keycache.ts
rename to packages/federation/src/activity-delivery/keycache.ts
diff --git a/packages/graphql/src/activity-delivery/outbound.test.ts b/packages/federation/src/activity-delivery/outbound.test.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/outbound.test.ts
rename to packages/federation/src/activity-delivery/outbound.test.ts
index 1d51be2..6a2e3a1 100644
--- a/packages/graphql/src/activity-delivery/outbound.test.ts
+++ b/packages/federation/src/activity-delivery/outbound.test.ts
@@ -18,11 +18,11 @@
import assert from "node:assert/strict";
import { it } from "node:test";
+import createFederation from "@drfed/federation";
import {
deliverActivity,
groupRecipients,
-} from "@drfed/graphql/activity-delivery";
-import createFederation from "@drfed/graphql/federation";
+} from "@drfed/federation/activity-delivery";
import type { Database } from "@drfed/models";
import {
type Context,
diff --git a/packages/graphql/src/activity-delivery/outbound.ts b/packages/federation/src/activity-delivery/outbound.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/outbound.ts
rename to packages/federation/src/activity-delivery/outbound.ts
index aa484bc..9e4e23c 100644
--- a/packages/graphql/src/activity-delivery/outbound.ts
+++ b/packages/federation/src/activity-delivery/outbound.ts
@@ -41,7 +41,7 @@ import {
} from "./queue.ts";
import { trackRequest } from "./tracking.ts";
-const logger = getLogger(["drfed", "graphql", "activity-delivery"]);
+const logger = getLogger(["drfed", "federation", "activity-delivery"]);
const queued = new WeakSet>();
diff --git a/packages/graphql/src/activity-delivery/queue.test.ts b/packages/federation/src/activity-delivery/queue.test.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/queue.test.ts
rename to packages/federation/src/activity-delivery/queue.test.ts
index a2b6ff5..e682251 100644
--- a/packages/graphql/src/activity-delivery/queue.test.ts
+++ b/packages/federation/src/activity-delivery/queue.test.ts
@@ -21,11 +21,11 @@ import assert from "node:assert/strict";
import { it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
+import createFederation from "@drfed/federation";
import {
deliverActivity,
queuedSettlements,
-} from "@drfed/graphql/activity-delivery";
-import createFederation from "@drfed/graphql/federation";
+} from "@drfed/federation/activity-delivery";
import type { Database } from "@drfed/models";
import {
type Context,
diff --git a/packages/graphql/src/activity-delivery/queue.ts b/packages/federation/src/activity-delivery/queue.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/queue.ts
rename to packages/federation/src/activity-delivery/queue.ts
index 81d8969..aa7d691 100644
--- a/packages/graphql/src/activity-delivery/queue.ts
+++ b/packages/federation/src/activity-delivery/queue.ts
@@ -42,7 +42,7 @@ import {
untracked,
} from "./tracking.ts";
-const logger = getLogger(["drfed", "graphql", "activity-delivery"]);
+const logger = getLogger(["drfed", "federation", "activity-delivery"]);
/** How a delivery settles, apart from which delivery it is. */
export type Settlement = Omit<
diff --git a/packages/federation/src/activity-delivery/remote.test.ts b/packages/federation/src/activity-delivery/remote.test.ts
new file mode 100644
index 0000000..8e95677
--- /dev/null
+++ b/packages/federation/src/activity-delivery/remote.test.ts
@@ -0,0 +1,75 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// A remote inbox served on a local port, for tests that run Fedify's own
+// delivery against it.
+import { once } from "node:events";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+
+/** A response the inbox answers with: a status, a body, and a `Location`. */
+export type InboxResponse = readonly [number, string, string?];
+
+/** A request the inbox received. */
+export interface InboxRequest {
+ readonly method: string;
+ readonly url: string;
+ readonly body: string;
+}
+
+/**
+ * Serve an inbox answering each request, whatever its path, with the next
+ * response, and keeping each request it received.
+ * With no responses, the port is closed and every connection is refused.
+ * @returns The result of the run.
+ */
+export async function withInbox(
+ responses: readonly InboxResponse[] | null,
+ run: (inbox: URL, received: readonly InboxRequest[]) => Promise,
+): Promise {
+ const pending = [...(responses ?? [])];
+ const received: InboxRequest[] = [];
+ const server = createServer((request, response) => {
+ const chunks: Buffer[] = [];
+ request.on("data", (chunk: Buffer) => chunks.push(chunk));
+ request.on("end", () => {
+ received.push({
+ method: request.method ?? "",
+ url: request.url ?? "",
+ body: Buffer.concat(chunks).toString("utf8"),
+ });
+ const [status, body, location] = pending.shift() ?? [500, "unexpected"];
+ response.statusCode = status;
+ if (location != null) response.setHeader("Location", location);
+ response.end(body);
+ });
+ });
+ server.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ const { port } = server.address() as AddressInfo;
+ const inbox = new URL(`http://127.0.0.1:${port}/inbox`);
+ const close = async () => {
+ server.closeAllConnections();
+ server.close();
+ await once(server, "close");
+ };
+ if (responses == null) await close();
+ try {
+ return await run(inbox, received);
+ } finally {
+ if (responses != null) await close();
+ }
+}
diff --git a/packages/graphql/src/activity-delivery/telemetry.ts b/packages/federation/src/activity-delivery/telemetry.ts
similarity index 100%
rename from packages/graphql/src/activity-delivery/telemetry.ts
rename to packages/federation/src/activity-delivery/telemetry.ts
diff --git a/packages/graphql/src/activity-delivery/tracking.ts b/packages/federation/src/activity-delivery/tracking.ts
similarity index 100%
rename from packages/graphql/src/activity-delivery/tracking.ts
rename to packages/federation/src/activity-delivery/tracking.ts
diff --git a/packages/graphql/src/activity-delivery/verification.ts b/packages/federation/src/activity-delivery/verification.ts
similarity index 99%
rename from packages/graphql/src/activity-delivery/verification.ts
rename to packages/federation/src/activity-delivery/verification.ts
index 6a874d3..5fbbdc7 100644
--- a/packages/graphql/src/activity-delivery/verification.ts
+++ b/packages/federation/src/activity-delivery/verification.ts
@@ -34,7 +34,7 @@ import type {
Report,
} from "./tracking.ts";
-const logger = getLogger(["drfed", "graphql", "activity-delivery"]);
+const logger = getLogger(["drfed", "federation", "activity-delivery"]);
export interface VerificationObservation {
readonly mechanism: ActivityDeliveryVerificationMechanism | null;
diff --git a/packages/federation/src/actor.ts b/packages/federation/src/actor.ts
new file mode 100644
index 0000000..f7bdd94
--- /dev/null
+++ b/packages/federation/src/actor.ts
@@ -0,0 +1,183 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import type { Database, schema } from "@drfed/models";
+import type { Actor, Resource } from "@drfed/models/schema";
+import { type Uuid, validateUuid } from "@drfed/models/uuid";
+import type { Context, FederationBuilder } from "@fedify/fedify";
+import {
+ Application,
+ Endpoints,
+ Group,
+ Image,
+ Organization,
+ Person,
+ Service,
+ Tombstone,
+} from "@fedify/vocab";
+
+import { canonicalizeAuthority } from "./origin.ts";
+
+/**
+ * The vocabulary object types that DrFed serves as actors.
+ */
+type ActorObject = Application | Group | Organization | Person | Service;
+
+type ActorProps = ConstructorParameters[0];
+
+const actorConstructors: Record<
+ Actor["type"],
+ (props: ActorProps) => ActorObject
+> = {
+ Application: (props) => new Application(props),
+ Group: (props) => new Group(props),
+ Organization: (props) => new Organization(props),
+ Person: (props) => new Person(props),
+ Service: (props) => new Service(props),
+};
+
+type StoredActor = Actor & {
+ resource: Resource;
+ collectionReferences: (typeof schema.actorCollectionReferences.$inferSelect & {
+ collection: typeof schema.collections.$inferSelect & { resource: Resource };
+ })[];
+};
+
+async function findLocalActor(
+ db: Database,
+ ctx: Context,
+ identifier: string,
+): Promise {
+ if (!validateUuid(identifier)) return null;
+ const actor = await db.query.actors.findFirst({
+ where: {
+ id: identifier as Uuid,
+ localId: { isNotNull: true },
+ instance: { host: canonicalizeAuthority(ctx.host) },
+ },
+ with: {
+ resource: true,
+ collectionReferences: {
+ with: { collection: { with: { resource: true } } },
+ },
+ },
+ });
+ return actor ?? null;
+}
+
+/**
+ * Looks up a local actor of the instance the request arrived on, the way the
+ * actor dispatcher does, but treats a deleted actor as missing.
+ * @param db The database to resolve the actor from.
+ * @param ctx The Fedify context of the request.
+ * @param identifier The actor identifier from the request path.
+ * @returns The actor, or `null` if it is not served.
+ */
+export async function findActiveActor(
+ db: Database,
+ ctx: Context,
+ identifier: string,
+): Promise {
+ const actor = await findLocalActor(db, ctx, identifier);
+ return actor == null || actor.deleted != null ? null : actor;
+}
+
+/**
+ * Registers the actor dispatcher and its handle mapper.
+ * @param builder The builder to register on.
+ * @param db The database to resolve local actors from.
+ */
+export function registerActorDispatcher(
+ builder: FederationBuilder,
+ db: Database,
+): void {
+ builder
+ .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
+ const actor = await findLocalActor(db, ctx, identifier);
+ if (actor == null) return null;
+ // Deleted actors are served as `Tombstone`s (HTTP 410) so that remote
+ // peers purge them instead of retrying on 404.
+ if (actor.deleted != null) {
+ return new Tombstone({ id: ctx.getActorUri(identifier) });
+ }
+ return toActorObject(ctx, identifier, actor);
+ })
+ // FIXME: https://github.com/fedify-dev/drfed/issues/87
+ .setKeyPairsDispatcher(() => [])
+ .mapHandle(async (ctx, username) => {
+ const actor = await db.query.actors.findFirst({
+ where: {
+ username,
+ localId: { isNotNull: true },
+ instance: { host: canonicalizeAuthority(ctx.host) },
+ deleted: { isNull: true },
+ },
+ });
+ return actor?.id ?? null;
+ });
+ // FIXME: https://github.com/fedify-dev/drfed/issues/87
+}
+
+// Whether a sanction is *currently* active is always determined by comparing
+// against the current time (lazy expiry; no cron); see the actors table.
+function isSuspended({ suspended, suspendedUntil }: Actor): boolean {
+ const now = Temporal.Now.instant();
+ return (
+ suspended != null &&
+ Temporal.Instant.compare(suspended, now) <= 0 &&
+ (suspendedUntil == null ||
+ Temporal.Instant.compare(suspendedUntil, now) > 0)
+ );
+}
+
+function toActorObject(
+ ctx: Context,
+ identifier: string,
+ actor: StoredActor,
+): ActorObject {
+ return actorConstructors[actor.type]({
+ id: new URL(actor.resource.iri),
+ preferredUsername: actor.username,
+ name: actor.name,
+ summary: actor.bioHtml,
+ url: actor.profileUrl == null ? null : new URL(actor.profileUrl),
+ icon:
+ actor.avatarUrl == null
+ ? null
+ : new Image({ url: new URL(actor.avatarUrl) }),
+ image:
+ actor.headerUrl == null
+ ? null
+ : new Image({ url: new URL(actor.headerUrl) }),
+ manuallyApprovesFollowers: !actor.automaticallyApprovesFollowers,
+ sensitive: actor.sensitive,
+ suspended: isSuspended(actor),
+ aliases: actor.aliases.map((alias) => new URL(alias)),
+ inbox: new URL(actor.inboxUrl),
+ outbox: collectionIri(actor, "outbox"),
+ followers: collectionIri(actor, "followers"),
+ following: collectionIri(actor, "following"),
+ featured: collectionIri(actor, "featured"),
+ endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
+ });
+}
+
+function collectionIri(actor: StoredActor, role: string): URL | null {
+ const reference = actor.collectionReferences.find(
+ (entry) => entry.role === role,
+ );
+ return reference == null ? null : new URL(reference.collection.resource.iri);
+}
diff --git a/packages/federation/src/collection.ts b/packages/federation/src/collection.ts
new file mode 100644
index 0000000..0b0eabb
--- /dev/null
+++ b/packages/federation/src/collection.ts
@@ -0,0 +1,204 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { type Database, schema } from "@drfed/models";
+import { type Uuid, validateUuid } from "@drfed/models/uuid";
+import type { Context, FederationBuilder } from "@fedify/fedify";
+import { Object as APObject } from "@fedify/vocab";
+import { and, eq, sql } from "drizzle-orm";
+
+import { findActiveActor } from "./actor.ts";
+import { activitySelection, toCreate } from "./object.ts";
+import { servedActivity } from "./visibility.ts";
+
+const OUTBOX_PAGE_SIZE = 20;
+
+/**
+ * Registers the dispatchers for the outbox, followers, following, and
+ * featured collections of local actors.
+ * @param builder The builder to register on.
+ * @param db The database to resolve the collections from.
+ */
+export function registerCollectionDispatchers(
+ builder: FederationBuilder,
+ db: Database,
+): void {
+ builder
+ .setOutboxDispatcher(
+ "/users/{identifier}/outbox",
+ async (ctx, identifier, cursor) => {
+ if ((await findActiveActor(db, ctx, identifier)) == null) return null;
+ const boundary = parseOutboxCursor(cursor);
+ if (boundary === false) return null;
+ const rows = await db.query.activities.findMany({
+ where: {
+ actorId: identifier as Uuid,
+ RAW: (table) =>
+ and(
+ servedActivity(table),
+ boundary == null
+ ? undefined
+ : sql`(${table.published}, ${table.id}) <
+ (${boundary.published}::timestamptz, ${boundary.id}::uuid)`,
+ )!,
+ },
+ // Backfilled activity IDs are UUIDv7, so only publication time
+ // determines chronology. Keep full database precision in cursors.
+ extras: {
+ cursorPublished: (table) =>
+ sql`to_char(${table.published} AT TIME ZONE 'UTC',
+ 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
+ },
+ orderBy: { published: "desc", id: "desc" },
+ limit: OUTBOX_PAGE_SIZE + 1,
+ with: activitySelection,
+ });
+ const page = rows.slice(0, OUTBOX_PAGE_SIZE);
+ return {
+ items: page.map((object) => toCreate(ctx, object)),
+ nextCursor:
+ rows.length > OUTBOX_PAGE_SIZE
+ ? `${page.at(-1)!.cursorPublished}|${page.at(-1)!.id}`
+ : null,
+ };
+ },
+ )
+ .setFirstCursor(async (ctx, identifier) =>
+ (await findActiveActor(db, ctx, identifier)) == null ? null : "",
+ )
+ .setCounter(async (ctx, identifier) => {
+ const actor = await findActiveActor(db, ctx, identifier);
+ return actor == null
+ ? null
+ : db.$count(
+ schema.activities,
+ and(
+ eq(schema.activities.actorId, actor.id),
+ servedActivity(schema.activities),
+ ),
+ );
+ });
+
+ builder
+ .setFollowersDispatcher(
+ "/users/{identifier}/followers",
+ async (ctx, identifier) => {
+ const collection = await actorCollection(
+ db,
+ ctx,
+ identifier,
+ "followers",
+ );
+ if (collection == null) return null;
+ // FollowersDispatcher requires actor inboxes for future delivery fan-out.
+ return {
+ items: collection.items.map(({ item }) => ({
+ id: new URL(item.iri),
+ inboxId: item.actor == null ? null : new URL(item.actor.inboxUrl),
+ })),
+ };
+ },
+ )
+ .setCounter(
+ async (ctx, identifier) =>
+ (await actorCollection(db, ctx, identifier, "followers"))?.items
+ .length ?? null,
+ );
+ builder
+ .setFollowingDispatcher(
+ "/users/{identifier}/following",
+ async (ctx, identifier) => {
+ const collection = await actorCollection(
+ db,
+ ctx,
+ identifier,
+ "following",
+ );
+ return collection == null
+ ? null
+ : { items: collection.items.map(({ item }) => new URL(item.iri)) };
+ },
+ )
+ .setCounter(
+ async (ctx, identifier) =>
+ (await actorCollection(db, ctx, identifier, "following"))?.items
+ .length ?? null,
+ );
+ builder.setFeaturedDispatcher(
+ "/users/{identifier}/featured",
+ async (ctx, identifier) => {
+ const collection = await actorCollection(db, ctx, identifier, "featured");
+ return collection == null
+ ? null
+ : {
+ items: collection.items.map(
+ ({ item }) => new APObject({ id: new URL(item.iri) }),
+ ),
+ };
+ },
+ );
+}
+
+/**
+ * Parse an opaque boundary without rounding database microseconds.
+ * @returns The boundary, null for the first page, or false for invalid input.
+ */
+function parseOutboxCursor(
+ cursor: string | null,
+): { published: string; id: string } | null | false {
+ if (cursor == null || cursor === "") return null;
+ const [published, id, extra] = cursor.split("|");
+ if (
+ published == null ||
+ published.startsWith("0000-") ||
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/u.test(published) ||
+ id == null ||
+ !validateUuid(id) ||
+ extra != null
+ ) {
+ return false;
+ }
+ try {
+ Temporal.Instant.from(published);
+ return { published, id };
+ } catch {
+ return false;
+ }
+}
+
+async function actorCollection(
+ db: Database,
+ ctx: Context,
+ identifier: string,
+ role: "followers" | "following" | "featured",
+) {
+ const actor = await findActiveActor(db, ctx, identifier);
+ if (actor == null) return null;
+ const reference = await db.query.actorCollectionReferences.findFirst({
+ where: { actorId: actor.id, role },
+ with: {
+ collection: {
+ with: {
+ items: {
+ orderBy: { position: "asc", itemId: "asc" },
+ with: { item: { with: { actor: true } } },
+ },
+ },
+ },
+ },
+ });
+ return reference?.collection ?? { items: [] };
+}
diff --git a/packages/graphql/src/federation.test.ts b/packages/federation/src/federation.test.ts
similarity index 85%
rename from packages/graphql/src/federation.test.ts
rename to packages/federation/src/federation.test.ts
index 72849ba..650ebe4 100644
--- a/packages/graphql/src/federation.test.ts
+++ b/packages/federation/src/federation.test.ts
@@ -19,8 +19,7 @@
import assert from "node:assert/strict";
-import { createYogaServer } from "@drfed/graphql";
-import createFederation, { buildFederation } from "@drfed/graphql/federation";
+import createFederation, { buildFederation } from "@drfed/federation";
import { schema } from "@drfed/models";
import { PUBLIC_IRI } from "@drfed/models/resource";
import { type Uuid, uuidV7 as uuid } from "@drfed/models/uuid";
@@ -29,18 +28,32 @@ import { Object as APObject, Create } from "@fedify/vocab";
import { describe, it } from "@logtape/testing-node/autoload";
import { eq, sql } from "drizzle-orm";
-import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts";
+import { withFederation, withTemporaryDatabase } from "./harness.test.ts";
import {
- globalId,
localActorId,
remoteActorId,
seedActors,
- seedAuthenticatedLocalInstance,
seedLocalActor,
seedObjects,
seedRemoteActor,
} from "./seed.test.ts";
+const actorIri = `https://test-instance.drfed.org/users/${localActorId}`;
+const createIri = (id: string) =>
+ `https://test-instance.drfed.org/ap/creates/${id}`;
+const accept = { accept: "application/activity+json" };
+
+function values(id: string) {
+ return {
+ id: id as Uuid,
+ activityId: uuid(),
+ actorId: localActorId as Uuid,
+ iri: `${actorIri}/${id}`,
+ type: "Note" as const,
+ contentHtml: "Hello
",
+ };
+}
+
const origin = new URL("https://drfed.test");
const activityJson = "application/activity+json";
@@ -174,38 +187,43 @@ describe("createFederation()", () => {
assert.notEqual(first, second);
});
});
-});
-describe("createYogaServer()", () => {
- it("does not mutate the federation instance", async () => {
- await withTestHarness(({ db, mailer, federation }) => {
- assert.doesNotThrow(() =>
- createYogaServer(db, federation, {
- mailer,
- loginOrigins: new Set(["https://drfed.test"]),
- rootOrigin: new URL("https://drfed.test"),
- }),
- );
+ it("keeps the database of each federation it builds", async () => {
+ // Two databases hold an actor under the same identifier and host, so only
+ // the database each federation was built from tells their answers apart.
+ // Both federations are built before either serves a request, which is
+ // what a builder shared across calls would get wrong.
+ await withTemporaryDatabase(async (first) => {
+ await withTemporaryDatabase(async (second) => {
+ for (const [db, name] of [
+ [first, "First"],
+ [second, "Second"],
+ ] as const) {
+ await seedLocalActor(db);
+ await db
+ .update(schema.actors)
+ .set({ name })
+ .where(eq(schema.actors.id, localActorId));
+ }
+ const federations = [
+ await createFederation(first, { kv: new MemoryKvStore() }),
+ await createFederation(second, { kv: new MemoryKvStore() }),
+ ];
+ const names = [];
+ for (const federation of federations) {
+ const response = await federation.fetch(
+ new Request(actorIri, { headers: accept }),
+ { contextData: undefined },
+ );
+ assert.equal(response.status, 200);
+ names.push((await response.json()).name);
+ }
+ assert.deepEqual(names, ["First", "Second"]);
+ });
});
});
});
-const actorIri = `https://test-instance.drfed.org/users/${localActorId}`;
-const createIri = (id: string) =>
- `https://test-instance.drfed.org/ap/creates/${id}`;
-const accept = { accept: "application/activity+json" };
-
-function values(id: string) {
- return {
- id: id as Uuid,
- activityId: uuid(),
- actorId: localActorId as Uuid,
- iri: `${actorIri}/${id}`,
- type: "Note" as const,
- contentHtml: "Hello
",
- };
-}
-
describe("ActivityPub resource origin spelling", () => {
for (const resource of ["object", "Create", "Tombstone"] as const) {
for (const requestOrigin of [
@@ -213,7 +231,7 @@ describe("ActivityPub resource origin spelling", () => {
"http://test-instance.drfed.org",
]) {
it(`serves ${resource} from ${requestOrigin} with its stored canonical IRI`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
const deleted =
@@ -247,7 +265,7 @@ describe("ActivityPub resource origin spelling", () => {
describe("ActivityPub objects", () => {
for (const publicProperty of ["to", "cc"] as const) {
it(`serves ${publicProperty} Public objects with contentMap and recipients`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, {
@@ -290,7 +308,7 @@ describe("ActivityPub objects", () => {
}
for (const deleted of [null, Temporal.Instant.from("2026-09-06T12:00:00Z")]) {
it(`does not serve followers-only objects (deleted: ${deleted != null})`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, {
@@ -311,7 +329,7 @@ describe("ActivityPub objects", () => {
});
}
it("serves Articles and tombstones", async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, { ...object, type: "Article" });
@@ -348,7 +366,7 @@ describe("ActivityPub objects", () => {
"deletedActor",
] as const) {
it(`rejects ${scenario} object requests`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
await seedRemoteActor(db);
const object = values(uuid());
@@ -385,7 +403,7 @@ describe("ActivityPub objects", () => {
describe("ActivityPub Create activities", () => {
for (const publicProperty of ["to", "cc"] as const) {
it(`serves Create activities for ${publicProperty} Public objects`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, {
@@ -432,7 +450,7 @@ describe("ActivityPub Create activities", () => {
"deletedActor",
] as const) {
it(`rejects ${scenario} Create requests`, async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
await seedRemoteActor(db);
const object = values(uuid());
@@ -466,7 +484,7 @@ describe("ActivityPub Create activities", () => {
});
}
it("rejects Create requests from another host", async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, object);
@@ -488,7 +506,7 @@ describe("ActivityPub Create activities", () => {
describe("ActivityPub outbox", () => {
it("paginates Create activities while excluding followers-only and deleted objects", async () => {
// oxlint-disable-next-line max-statements
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const ids = Array.from({ length: 23 }, () => uuid());
const activityIds = ids.map(() => uuid());
@@ -563,7 +581,7 @@ describe("ActivityPub outbox", () => {
});
});
it("orders UUIDv4 backfills by publication and retains microseconds across pages", async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const objects = Array.from({ length: 24 }, () => uuid());
for (const [index, id] of objects.entries()) {
@@ -614,7 +632,7 @@ describe("ActivityPub outbox", () => {
});
});
it("serves an empty outbox", async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const response = await federation.fetch(
new Request(`${actorIri}/outbox?cursor=`, { headers: accept }),
@@ -629,65 +647,9 @@ describe("ActivityPub outbox", () => {
});
});
-const createMutation = `mutation Create($actor: ID!, $addressing: AddressingInput!) {
- createObject(actor: $actor, contentHtml: "Hello
", addressing: $addressing) {
- ... on Object { uuid }
- ... on CreateObjectError { errorType: type message }
- }
-}`;
-
-// Regression tests for
-// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252:
-// The outbox counter must match its page predicate rather than a stored count.
-describe("ActivityPub outbox totalItems", () => {
- for (const scenario of ["followers", "deleted"] as const) {
- it(`does not count ${scenario} objects that outbox pages never return`, async () => {
- await withTestHarness(async ({ db, federation, post }) => {
- const auth = await seedAuthenticatedLocalInstance(db);
- await seedLocalActor(db);
- const body = await (
- await post(
- {
- query: createMutation,
- variables: {
- actor: globalId("Actor", localActorId),
- addressing:
- scenario === "followers"
- ? { to: [`${actorIri}/followers`] }
- : { to: [PUBLIC_IRI] },
- },
- },
- auth,
- )
- ).json();
- assert.equal(body.errors, undefined);
- assert.equal(body.data.createObject.errorType, undefined);
- if (scenario === "deleted") {
- await db
- .update(schema.objects)
- .set({ deleted: Temporal.Now.instant() })
- .where(eq(schema.objects.id, body.data.createObject.uuid));
- }
- const fetchJson = async (iri: string) => {
- const response = await federation.fetch(
- new Request(iri, { headers: accept }),
- { contextData: undefined },
- );
- assert.equal(response.status, 200);
- return await response.json();
- };
- const page = await fetchJson(`${actorIri}/outbox?cursor=`);
- assert.deepEqual(page.orderedItems ?? [], []);
- const collection = await fetchJson(`${actorIri}/outbox`);
- assert.equal(collection.totalItems, 0);
- });
- });
- }
-});
-
describe("stored collection membership and independent activity addressing", () => {
it("serves stored Create IRIs and uses activity addressing for outbox and Create", async () => {
- await withTestHarness(async ({ db, federation }) => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
const object = values(uuid());
await seedObjects(db, object);
@@ -723,8 +685,8 @@ describe("stored collection membership and independent activity addressing", ()
assert.ok(rows.length > 0);
});
});
- it("reads collection_items for followers, following, featured and GraphQL items", async () => {
- await withTestHarness(async ({ db, federation, post }) => {
+ it("reads collection_items for followers, following and featured", async () => {
+ await withFederation(async ({ db, federation }) => {
await seedLocalActor(db);
await seedRemoteActor(db);
const fetch = (iri: string) =>
@@ -752,19 +714,6 @@ describe("stored collection membership and independent activity addressing", ()
"https://remote.example.com/users/bob",
);
}
- const body = await (
- await post({
- query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { resource { kind } totalCount items(first: 1) { edges { cursor node { kind iri detail { ... on Actor { username } } } } pageInfo { hasNextPage } } } } } }`,
- variables: { id: globalId("Actor", localActorId) },
- })
- ).json();
- assert.equal(body.errors, undefined);
- assert.equal(body.data.node.followers.totalCount, 1);
- assert.deepEqual(body.data.node.followers.items.edges[0].node, {
- kind: "actor",
- iri: "https://remote.example.com/users/bob",
- detail: { username: "bob" },
- });
});
});
});
diff --git a/packages/federation/src/harness.test.ts b/packages/federation/src/harness.test.ts
new file mode 100644
index 0000000..c9899e3
--- /dev/null
+++ b/packages/federation/src/harness.test.ts
@@ -0,0 +1,94 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import createFederation from "@drfed/federation";
+import { type Database, migrate, relations, schema } from "@drfed/models";
+import { PGlite } from "@electric-sql/pglite";
+import { type Federation, MemoryKvStore } from "@fedify/fedify";
+import { drizzle } from "drizzle-orm/pglite";
+
+let databaseSnapshot: Promise | undefined;
+
+async function createDatabaseSnapshot(): Promise {
+ const client = new PGlite();
+ try {
+ await client.waitReady;
+ await migrate({ credentials: { driver: "pglite", client } });
+ return await client.dumpDataDir("none");
+ } finally {
+ await client.close();
+ }
+}
+
+function getDatabaseSnapshot(): Promise {
+ databaseSnapshot ??= createDatabaseSnapshot();
+ return databaseSnapshot;
+}
+
+/**
+ * Utilities for testing the federation against a temporary database.
+ */
+export interface FederationHarness {
+ /**
+ * The migrated temporary database.
+ */
+ readonly db: Database;
+
+ /**
+ * The federation built from {@link db} with an in-memory KV store.
+ */
+ readonly federation: Federation;
+}
+
+/**
+ * Runs a callback with a fresh in-memory PGlite database.
+ *
+ * Each database is restored from a lazily cached, migrated snapshot, so every
+ * table in the current `@drfed/models` schema is available. The underlying
+ * PGlite client is closed after the callback resolves or rejects.
+ * @param callback A function that receives the migrated database.
+ * @returns The callback's resolved value.
+ */
+export async function withTemporaryDatabase(
+ // oxlint-disable-next-line promise/prefer-await-to-callbacks
+ callback: (db: Database) => Promise | T,
+): Promise> {
+ const client = new PGlite({ loadDataDir: await getDatabaseSnapshot() });
+ try {
+ await client.waitReady;
+ const db: Database = drizzle({ client, relations, schema });
+ // oxlint-disable-next-line promise/prefer-await-to-callbacks
+ return await callback(db);
+ } finally {
+ await client.close();
+ }
+}
+
+/**
+ * Runs a callback with a federation backed by a temporary database.
+ * @param callback A function that receives the harness.
+ * @returns The callback's resolved value.
+ */
+export async function withFederation(
+ // oxlint-disable-next-line promise/prefer-await-to-callbacks
+ callback: (harness: FederationHarness) => Promise | T,
+): Promise> {
+ return await withTemporaryDatabase(async (db) => {
+ const federation = await createFederation(db, { kv: new MemoryKvStore() });
+ // oxlint-disable-next-line promise/prefer-await-to-callbacks
+ return await callback({ db, federation });
+ });
+}
diff --git a/packages/federation/src/inbox.ts b/packages/federation/src/inbox.ts
new file mode 100644
index 0000000..a254613
--- /dev/null
+++ b/packages/federation/src/inbox.ts
@@ -0,0 +1,44 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import type { FederationBuilder } from "@fedify/fedify";
+import { Activity } from "@fedify/vocab";
+import { getLogger } from "@logtape/logtape";
+
+import { markHandled } from "./activity-delivery/tracking.ts";
+
+const logger = getLogger(["drfed", "federation"]);
+
+/**
+ * Registers the personal and shared inbox listeners.
+ * @param builder The builder to register on.
+ */
+export function registerInboxListeners(
+ builder: FederationBuilder,
+): void {
+ builder
+ .setInboxListeners("/users/{identifier}/inbox", "/inbox")
+ // FIXME: https://github.com/fedify-dev/drfed/issues/88
+ .on(Activity, (_ctx, activity) => {
+ markHandled();
+ logger.debug("Received an activity: {activity}", { activity });
+ })
+ .onError((_ctx, error) => {
+ logger.error("An error occurred while processing an inbox: {error}", {
+ error,
+ });
+ });
+}
diff --git a/packages/federation/src/index.ts b/packages/federation/src/index.ts
new file mode 100644
index 0000000..5973310
--- /dev/null
+++ b/packages/federation/src/index.ts
@@ -0,0 +1,106 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import type { Database } from "@drfed/models";
+import {
+ type FederationBuilder,
+ type FederationOptions,
+ createFederationBuilder,
+} from "@fedify/fedify";
+import { metrics, trace } from "@opentelemetry/api";
+
+import { markQueued } from "./activity-delivery/outbound.ts";
+import {
+ observeQueues,
+ outboxQueue,
+ reportOutboxError,
+ reportPermanentFailure,
+} from "./activity-delivery/queue.ts";
+import { trackMetrics, trackSpans } from "./activity-delivery/telemetry.ts";
+import {
+ type TrackedFederation,
+ attachKv,
+ trackPublicKeys,
+} from "./activity-delivery/tracking.ts";
+import { registerActorDispatcher } from "./actor.ts";
+import { registerCollectionDispatchers } from "./collection.ts";
+import { registerInboxListeners } from "./inbox.ts";
+import { registerObjectDispatchers } from "./object-dispatchers.ts";
+
+export { createInboundRecorder } from "./activity-delivery/inbound.ts";
+export type { TrackedFederation } from "./activity-delivery/tracking.ts";
+export { deliverActivity } from "./activity-delivery/outbound.ts";
+
+/**
+ * Creates a `FederationBuilder` with every ActivityPub dispatcher and
+ * listener that DrFed serves registered on it. The registered paths define
+ * the URI layout of the federated objects, which makes the object URI getters
+ * (e.g. `Context.getActorUri()`) available once the builder is built.
+ * @param db The database to resolve local actors from.
+ * @returns A builder that has not been built yet.
+ */
+export function buildFederation(db: Database): FederationBuilder {
+ const builder = createFederationBuilder();
+ registerActorDispatcher(builder, db);
+ registerInboxListeners(builder);
+ registerObjectDispatchers(builder, db);
+ registerCollectionDispatchers(builder, db);
+ builder.setOutboxPermanentFailureHandler(reportPermanentFailure);
+ return builder;
+}
+
+/**
+ * Creates a `Federation` instance with every DrFed dispatcher registered.
+ * Every registration happens on a fresh builder inside this function, so the
+ * returned instance is complete and must not be mutated further.
+ * The queues, if any, are observed so that each delivery attempt settles its
+ * outbound delivery and each queued inbox listener run its inbound delivery.
+ * The public-key cache and the spans and measurements Fedify reports are
+ * tracked so that `createInboundRecorder()` sees how each inbox request was
+ * verified, and with which key.
+ * @param db The database to resolve local actors from.
+ * @param options Options for the underlying Fedify `Federation`, such as
+ * the `kv` store.
+ * @returns The built `Federation` instance.
+ */
+export default async function createFederation(
+ db: Database,
+ options: FederationOptions,
+): Promise {
+ const federation = await buildFederation(db).build({
+ ...options,
+ kv: trackPublicKeys(
+ options.kv,
+ options.kvPrefixes?.publicKey ?? ["_fedify", "publicKey"],
+ ),
+ tracerProvider: trackSpans(
+ options.tracerProvider ?? trace.getTracerProvider(),
+ ),
+ meterProvider: trackMetrics(
+ options.meterProvider ?? metrics.getMeterProvider(),
+ ),
+ ...(options.queue == null
+ ? {}
+ : { queue: observeQueues(db, options.kv, options.queue) }),
+ async onOutboxError(error, activity) {
+ await reportOutboxError(error, activity);
+ await options.onOutboxError?.(error, activity);
+ },
+ });
+ if (outboxQueue(options.queue) != null) markQueued(federation);
+ attachKv(federation, options.kv);
+ return federation as TrackedFederation;
+}
diff --git a/packages/federation/src/object-dispatchers.ts b/packages/federation/src/object-dispatchers.ts
new file mode 100644
index 0000000..b99e5fe
--- /dev/null
+++ b/packages/federation/src/object-dispatchers.ts
@@ -0,0 +1,89 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import type { Database } from "@drfed/models";
+import { validateUuid } from "@drfed/models/uuid";
+import type { FederationBuilder } from "@fedify/fedify";
+import { Object as APObject, Create, Tombstone } from "@fedify/vocab";
+
+import {
+ activitySelection,
+ objectSelection,
+ toCreate,
+ toObject,
+} from "./object.ts";
+import { canonicalizeAuthority } from "./origin.ts";
+import { publicAddressing, servedActivity } from "./visibility.ts";
+
+/**
+ * Registers the dispatchers for local objects and their `Create` activities.
+ * @param builder The builder to register on.
+ * @param db The database to resolve the objects from.
+ */
+export function registerObjectDispatchers(
+ builder: FederationBuilder,
+ db: Database,
+): void {
+ builder.setObjectDispatcher(
+ APObject,
+ "/users/{identifier}/{id}",
+ async (ctx, { identifier, id }) => {
+ if (!validateUuid(identifier) || !validateUuid(id)) return null;
+ const object = await db.query.objects.findFirst({
+ where: {
+ id,
+ actorId: identifier,
+ RAW: (table) => publicAddressing(table.id),
+ actor: {
+ localId: { isNotNull: true },
+ deleted: { isNull: true },
+ instance: { host: canonicalizeAuthority(ctx.host) },
+ },
+ },
+ with: objectSelection,
+ });
+ if (object == null) return null;
+ if (object.deleted != null) {
+ return new Tombstone({
+ id: new URL(object.resource.iri),
+ deleted: object.deleted,
+ });
+ }
+ return toObject(ctx, object);
+ },
+ );
+
+ builder.setObjectDispatcher(
+ Create,
+ "/ap/creates/{id}",
+ async (ctx, { id }) => {
+ if (!validateUuid(id)) return null;
+ const activity = await db.query.activities.findFirst({
+ where: {
+ id,
+ actor: {
+ localId: { isNotNull: true },
+ deleted: { isNull: true },
+ instance: { host: canonicalizeAuthority(ctx.host) },
+ },
+ RAW: (table) => servedActivity(table),
+ },
+ with: activitySelection,
+ });
+ return activity == null ? null : toCreate(ctx, activity);
+ },
+ );
+}
diff --git a/packages/federation/src/object.ts b/packages/federation/src/object.ts
new file mode 100644
index 0000000..13ba7c5
--- /dev/null
+++ b/packages/federation/src/object.ts
@@ -0,0 +1,134 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import type {
+ ActivityPubObject,
+ Actor,
+ Addressing,
+ ObjectType,
+ Resource,
+ StoredActivity,
+} from "@drfed/models/schema";
+import type { Context } from "@fedify/fedify";
+import {
+ Object as APObject,
+ Article,
+ Create,
+ LanguageString,
+ Note,
+} from "@fedify/vocab";
+
+type ObjectProps = ConstructorParameters[0];
+const objectConstructors: Record APObject> =
+ {
+ Article: (props) => new Article(props),
+ Note: (props) => new Note(props),
+ };
+
+type StoredAddressing = Addressing & { targetResource: Resource };
+export const objectSelection = {
+ resource: true,
+ actor: { with: { resource: true } },
+ addressing: { with: { targetResource: true }, orderBy: { position: "asc" } },
+} as const;
+export const activitySelection = {
+ resource: true,
+ actor: { with: { resource: true } },
+ object: true,
+ addressing: { with: { targetResource: true }, orderBy: { position: "asc" } },
+} as const;
+
+/**
+ * A stored object loaded with {@link objectSelection}, as {@link toObject}
+ * serializes it.
+ */
+export type StoredObject = ActivityPubObject & {
+ resource: Resource;
+ actor: Actor & { resource: Resource };
+ addressing: StoredAddressing[];
+};
+
+/**
+ * A stored `Create` activity loaded with {@link activitySelection}, as
+ * {@link toCreate} serializes it.
+ */
+export type StoredCreate = StoredActivity & {
+ resource: Resource;
+ actor: Actor & { resource: Resource };
+ object: Resource | null;
+ addressing: StoredAddressing[];
+};
+
+function recipients(rows: readonly StoredAddressing[]): {
+ tos: URL[];
+ ccs: URL[];
+ audiences: URL[];
+} {
+ const values = (property: string): URL[] =>
+ rows
+ .filter((entry) => entry.property === property)
+ .toSorted((left, right) => left.position - right.position)
+ .map((entry) => new URL(entry.targetResource.iri));
+ return {
+ tos: values("to"),
+ ccs: values("cc"),
+ audiences: values("audience"),
+ };
+}
+
+/**
+ * Serializes stored object addressing; blind recipients stay in the database.
+ * @returns The vocabulary object without blind recipients.
+ */
+export function toObject(
+ _ctx: Context,
+ object: StoredObject,
+): APObject {
+ return objectConstructors[object.type]({
+ id: new URL(object.resource.iri),
+ attribution: new URL(object.actor.resource.iri),
+ contents: [
+ object.contentHtml,
+ ...(object.language == null
+ ? []
+ : [new LanguageString(object.contentHtml, object.language)]),
+ ],
+ name: object.name,
+ summary: object.summary,
+ sensitive: object.sensitive,
+ published: object.published,
+ updated: object.updated,
+ url: object.url == null ? null : new URL(object.url),
+ ...recipients(object.addressing),
+ });
+}
+
+/**
+ * Serializes a persisted Create activity, retaining its own IRI and addressing.
+ * @returns The vocabulary activity without blind recipients.
+ */
+export function toCreate(
+ _ctx: Context,
+ activity: StoredCreate,
+): Create {
+ return new Create({
+ id: new URL(activity.resource.iri),
+ actor: new URL(activity.actor.resource.iri),
+ ...recipients(activity.addressing),
+ object: activity.object == null ? null : new URL(activity.object.iri),
+ published: activity.published,
+ });
+}
diff --git a/packages/graphql/src/origin.test.ts b/packages/federation/src/origin.test.ts
similarity index 99%
rename from packages/graphql/src/origin.test.ts
rename to packages/federation/src/origin.test.ts
index 06bba66..2ef6b59 100644
--- a/packages/graphql/src/origin.test.ts
+++ b/packages/federation/src/origin.test.ts
@@ -22,7 +22,7 @@ import {
classifyHost,
instanceHost,
instanceOrigin,
-} from "@drfed/graphql/origin";
+} from "@drfed/federation/origin";
import { describe, it } from "@logtape/testing-node/autoload";
const production = new URL("https://drfed.net");
diff --git a/packages/graphql/src/origin.ts b/packages/federation/src/origin.ts
similarity index 100%
rename from packages/graphql/src/origin.ts
rename to packages/federation/src/origin.ts
diff --git a/packages/federation/src/seed.test.ts b/packages/federation/src/seed.test.ts
new file mode 100644
index 0000000..953e29a
--- /dev/null
+++ b/packages/federation/src/seed.test.ts
@@ -0,0 +1,210 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Keep dependent database writes and observations sequential.
+// oxlint-disable no-await-in-loop
+
+import {
+ type Database,
+ addActorCollectionItem,
+ promoteResource,
+ schema,
+ storeAddressing,
+} from "@drfed/models";
+import { type AddressingInput, PUBLIC_IRI } from "@drfed/models/resource";
+import { type Uuid, uuidV7 } from "@drfed/models/uuid";
+import type { PgInsertValue } from "drizzle-orm/pg-core";
+
+export const created = Temporal.Instant.from("2026-08-04T00:00:00.000Z");
+export const expires = Temporal.Instant.from("2030-08-04T00:00:00.000Z");
+
+export const localInstanceId = "00000000-0000-4000-8000-000000000101";
+export const remoteInstanceId = "00000000-0000-4000-8000-000000000102";
+export const localActorId = "00000000-0000-4000-8000-000000000201" as const;
+export const remoteActorId = "00000000-0000-4000-8000-000000000202" as const;
+
+export async function seedLocalActor(db: Database): Promise {
+ await seedLocalInstance(db);
+ await db.insert(schema.localActors).values({
+ id: localActorId,
+ avatar: "avatar.png",
+ header: "header.png",
+ });
+ await seedActors(db, {
+ id: localActorId,
+ localId: localActorId,
+ instanceId: localInstanceId,
+ type: "Person",
+ username: "alice",
+ iri: `https://test-instance.drfed.org/users/${localActorId}`,
+ inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`,
+ avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`,
+ headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`,
+ profileUrl: "https://test-instance.drfed.org/@alice",
+ created,
+ });
+}
+
+export async function seedLocalInstance(
+ db: Database,
+ host = "test-instance.drfed.org",
+): Promise {
+ await db
+ .insert(schema.localInstances)
+ .values({
+ id: localInstanceId,
+ slug: "test-instance",
+ expires,
+ })
+ .onConflictDoNothing();
+ await db
+ .insert(schema.instances)
+ .values({
+ id: localInstanceId,
+ localId: localInstanceId,
+ created,
+ host,
+ })
+ .onConflictDoNothing();
+}
+
+export async function seedRemoteActor(db: Database): Promise {
+ await db.insert(schema.instances).values({
+ id: remoteInstanceId,
+ created,
+ host: "remote.example.com",
+ });
+ await seedActors(db, {
+ id: remoteActorId,
+ instanceId: remoteInstanceId,
+ type: "Service",
+ username: "bob",
+ iri: "https://remote.example.com/users/bob",
+ inboxUrl: "https://remote.example.com/users/bob/inbox",
+ avatarUrl: "https://remote.example.com/users/bob/avatar.png",
+ headerUrl: "https://remote.example.com/users/bob/header.png",
+ profileUrl: "https://remote.example.com/@bob",
+ created,
+ });
+}
+
+type ActorSeed = PgInsertValue & {
+ id: Uuid;
+ iri: string;
+};
+export async function seedActors(
+ db: Database,
+ values: ActorSeed | ActorSeed[],
+): Promise {
+ for (const { iri, ...actor } of Array.isArray(values) ? values : [values]) {
+ await promoteResource(
+ db,
+ iri,
+ "actor",
+ async (tx, resource) => {
+ await tx.insert(schema.actors).values({ ...actor, id: resource.id });
+ for (const role of [
+ "followers",
+ "following",
+ "featured",
+ "outbox",
+ ] as const) {
+ await promoteResource(
+ tx,
+ `${iri}/${role}`,
+ "collection",
+ async (inner, collection) => {
+ await inner.insert(schema.collections).values({
+ id: collection.id,
+ type: "OrderedCollection",
+ ownerActorId: resource.id,
+ });
+ await inner.insert(schema.actorCollectionReferences).values({
+ actorId: resource.id,
+ role,
+ collectionId: collection.id,
+ });
+ },
+ );
+ }
+ },
+ actor.id,
+ );
+ }
+}
+type ObjectSeed = PgInsertValue & {
+ id: Uuid;
+ iri: string;
+ addressing?: AddressingInput;
+ activityId?: Uuid;
+};
+/** Seeds independent Create rows using the production activity ID layout. */
+export async function seedObjects(
+ db: Database,
+ values: ObjectSeed | ObjectSeed[],
+): Promise {
+ for (const {
+ iri,
+ activityId = uuidV7(),
+ addressing = {
+ to: [PUBLIC_IRI],
+ cc: [`https://test-instance.drfed.org/users/${localActorId}/followers`],
+ },
+ ...object
+ } of Array.isArray(values) ? values : [values]) {
+ await promoteResource(
+ db,
+ iri,
+ "object",
+ async (tx, resource) => {
+ const [row] = await tx
+ .insert(schema.objects)
+ .values({ ...object, id: resource.id })
+ .returning();
+ if (row == null) throw new Error("Missing seeded object.");
+ await storeAddressing(tx, resource.id, addressing);
+ const actor = await tx.query.actors.findFirst({
+ where: { id: row.actorId },
+ with: { instance: true },
+ });
+ if (actor == null) throw new Error("Missing seeded actor.");
+ await promoteResource(
+ tx,
+ `https://${actor.instance.host}/ap/creates/${activityId}`,
+ "activity",
+ async (inner, activity) => {
+ await inner.insert(schema.activities).values({
+ id: activity.id,
+ type: "Create",
+ actorId: row.actorId,
+ objectId: row.id,
+ published: row.published,
+ });
+ await storeAddressing(inner, activity.id, addressing);
+ await addActorCollectionItem(
+ inner,
+ row.actorId,
+ "outbox",
+ activity.id,
+ );
+ },
+ activityId,
+ );
+ },
+ object.id,
+ );
+ }
+}
diff --git a/packages/federation/src/visibility.ts b/packages/federation/src/visibility.ts
new file mode 100644
index 0000000..1e9945c
--- /dev/null
+++ b/packages/federation/src/visibility.ts
@@ -0,0 +1,42 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { schema } from "@drfed/models";
+import { PUBLIC_RESOURCE_ID } from "@drfed/models/resource";
+import { type SQL, type SQLWrapper, sql } from "drizzle-orm";
+
+/**
+ * Shared Public predicate for object, activity, outbox page and counter.
+ * @returns An EXISTS predicate matching explicit Public addressing.
+ */
+export function publicAddressing(sourceId: SQLWrapper): SQL {
+ return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
+}
+
+/**
+ * Matches the activities that are served over ActivityPub: Public `Create`
+ * activities whose object has not been deleted. The Create dispatcher, the
+ * outbox pages and the outbox counter share it, so that none of them can
+ * disagree with the others about what is served.
+ * @returns A predicate on the activities table.
+ */
+export function servedActivity(table: {
+ id: SQLWrapper;
+ objectId: SQLWrapper;
+ type: SQLWrapper;
+}): SQL {
+ return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`;
+}
diff --git a/packages/federation/tsconfig.json b/packages/federation/tsconfig.json
new file mode 100644
index 0000000..a9b0e0e
--- /dev/null
+++ b/packages/federation/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["DOM", "ESNEXT"],
+ "types": ["node"],
+ "paths": {
+ "@drfed/models": ["../models/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/graphql/README.md b/packages/graphql/README.md
index b408389..d8e1347 100644
--- a/packages/graphql/README.md
+++ b/packages/graphql/README.md
@@ -23,8 +23,8 @@ Usage
-----
~~~~ ts
+import createFederation, { createInboundRecorder } from "@drfed/federation";
import { createYogaServer } from "@drfed/graphql";
-import createFederation, { createInboundRecorder } from "@drfed/graphql/federation";
const federation = await createFederation(db, { kv });
const recordedInbox = createInboundRecorder({ db, federation, rootOrigin });
@@ -37,11 +37,13 @@ serve({
});
~~~~
-`createFederation` builds a Fedify `Federation` with every DrFed dispatcher
-registered. `createYogaServer` accepts a Drizzle database instance, that
-federation, and server options, and returns a GraphQL Yoga server
-ready to handle HTTP requests. The federation is stored in the resolver
-context as is; `createYogaServer` never registers anything on it.
+`createFederation`, from [`@drfed/federation`], builds a Fedify `Federation`
+with every DrFed dispatcher registered. `createYogaServer` accepts a Drizzle
+database instance, that federation, and server options, and returns a GraphQL
+Yoga server ready to handle HTTP requests. The federation is stored in the
+resolver context as is; `createYogaServer` never registers anything on it.
+
+[`@drfed/federation`]: https://github.com/fedify-dev/drfed/tree/main/packages/federation
Activity deliveries
@@ -62,7 +64,8 @@ delivery, whose `actor` is null, still explains why it is in the actor's feed.
`ActivityDelivery.attempts` is a connection, oldest attempt first.
Wrap the federation HTTP surface with `createInboundRecorder`, passing a
-federation made by `createFederation` and the root origin. Every inbox `POST`
+federation made by `createFederation` and the root origin; both come from
+[`@drfed/federation`]. Every inbox `POST`
is recorded, whether or not its body is JSON; recording errors never replace
federation responses. A request Fedify throws on is recorded too, with the
exception in `error` and no `statusCode`, and the exception is thrown again.
@@ -123,26 +126,26 @@ An inbound delivery keeps:
rotation times or evidence of continuous use. `Key` and `KeyVersion` hold
public material only and are readable by any authenticated viewer.
-`deliverActivity` is the outbound entry point for explicit recipients once
-local actor keys are available (#87). It removes `bto` and `bcc` before
-delivery, records one row per destination inbox with every recipient sharing
-it in `recipientIris`, and settles each delivery independently. A recipient
-without an ID or an inbox is left out, because Fedify does not deliver to it.
-The recorded `payload` is the document before signing. `attempts` keeps every
-attempt that ended, with the status the remote inbox answered, read from the
-responses `fetch()` publishes on `diagnostics_channel`, and the causes of
-network errors. When the inbox redirects a delivery, the status is the one
-the redirects ended with, whether Fedify followed them, as it does when it
-signs the request, or `fetch()` did. With a message queue, `createFederation`
-observes Fedify's outbox worker, and each queued message carries the delivery
-it belongs to. A delivery becomes `sent` when Fedify reports
-`activitypub.activity.sent` for its inbox, stays `failed` while it is retried,
-and ends `permanently_failed`, or `abandoned` when Fedify measures it abandoned
-after its retries ran out. A delivery Fedify returns from without sending to
-the inbox, or without enqueuing a message to it, is `permanently_failed` with
-no attempt, since Fedify makes none. The queue is handed to Fedify as one
-without native retries, so that every retry follows Fedify's policy and is
-recorded. Activity resource persistence (#88),
+`deliverActivity`, also from [`@drfed/federation`], is the outbound entry point
+for explicit recipients once local actor keys are available (#87). It removes
+`bto` and `bcc` before delivery, records one row per destination inbox with
+every recipient sharing it in `recipientIris`, and settles each delivery
+independently. A recipient without an ID or an inbox is left out, because
+Fedify does not deliver to it. The recorded `payload` is the document before
+signing. `attempts` keeps every attempt that ended, with the status the remote
+inbox answered, read from the responses `fetch()` publishes on
+`diagnostics_channel`, and the causes of network errors. When the inbox
+redirects a delivery, the status is the one the redirects ended with, whether
+Fedify followed them, as it does when it signs the request, or `fetch()` did.
+With a message queue, `createFederation` observes Fedify's outbox worker, and
+each queued message carries the delivery it belongs to. A delivery becomes
+`sent` when Fedify reports `activitypub.activity.sent` for its inbox, stays
+`failed` while it is retried, and ends `permanently_failed`, or `abandoned`
+when Fedify measures it abandoned after its retries ran out. A delivery Fedify
+returns from without sending to the inbox, or without enqueuing a message to
+it, is `permanently_failed` with no attempt, since Fedify makes none. The
+queue is handed to Fedify as one without native retries, so that every retry
+follows Fedify's policy and is recorded. Activity resource persistence (#88),
retention policies, and the activity-log UI (#13) are separate.
URL fields hold only values that parse as URLs, and no text field holds
@@ -151,3 +154,20 @@ U+0000; anything else a remote server sent stays in `rawBody` and
`responseBody` keep what a remote server answered with U+FFFD for each U+0000.
Deliveries are ordered by `created`, which for an inbound delivery is when the
request arrived; `completed` is when DrFed answered it.
+
+
+Moved exports
+-------------
+
+The ActivityPub code that used to live in this package moved to
+[`@drfed/federation`], which does not depend on GraphQL:
+
+| Previous import | Replacement |
+| ------------------------------------------------------ | ------------------------------------- |
+| `@drfed/graphql/federation` (factory functions) | `@drfed/federation` |
+| `@drfed/graphql/federation` (selections, serializers) | `@drfed/federation/object` |
+| `@drfed/graphql/origin` | `@drfed/federation/origin` |
+| `@drfed/graphql/activity-delivery` (runtime functions) | `@drfed/federation/activity-delivery` |
+
+`@drfed/graphql/activity-delivery` now holds only the GraphQL types for
+activity deliveries.
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 9095a91..fa842a0 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -62,10 +62,6 @@
"types": "./dist/classification.d.mts",
"default": "./dist/classification.mjs"
},
- "./federation": {
- "types": "./dist/federation.d.mts",
- "default": "./dist/federation.mjs"
- },
"./instance": {
"types": "./dist/instance.d.mts",
"default": "./dist/instance.mjs"
@@ -78,10 +74,6 @@
"types": "./dist/object.d.mts",
"default": "./dist/object.mjs"
},
- "./origin": {
- "types": "./dist/origin.d.mts",
- "default": "./dist/origin.mjs"
- },
"./activity-delivery": {
"types": "./dist/activity-delivery/entry.d.mts",
"default": "./dist/activity-delivery/entry.mjs"
@@ -98,11 +90,9 @@
"src/actor.ts",
"src/builder.ts",
"src/classification.ts",
- "src/federation.ts",
"src/instance.ts",
"src/schema.ts",
"src/object.ts",
- "src/origin.ts",
"src/activity-delivery/entry.ts"
],
"dts": {
@@ -123,6 +113,7 @@
"typescript": "catalog:"
},
"dependencies": {
+ "@drfed/federation": "workspace:*",
"@drfed/models": "workspace:*",
"@faker-js/faker": "catalog:",
"@fedify/uri-template": "^2.3.1",
@@ -130,7 +121,6 @@
"@fedify/vocab": "catalog:",
"@logtape/graphql-yoga": "catalog:",
"@logtape/logtape": "catalog:",
- "@opentelemetry/api": "^1.9.1",
"@pothos/core": "^4.13.0",
"@pothos/plugin-drizzle": "^0.17.4",
"@pothos/plugin-errors": "^4.9.1",
diff --git a/packages/graphql/src/activity-delivery.test.ts b/packages/graphql/src/activity-delivery.test.ts
index 38bc6d0..a062c83 100644
--- a/packages/graphql/src/activity-delivery.test.ts
+++ b/packages/graphql/src/activity-delivery.test.ts
@@ -20,16 +20,14 @@
import assert from "node:assert/strict";
import { it } from "node:test";
+import createFederation, { type TrackedFederation } from "@drfed/federation";
import {
classifyInbound,
createInboundRecorder,
createKeyCache,
deliverActivity,
describeActivity,
-} from "@drfed/graphql/activity-delivery";
-import createFederation, {
- type TrackedFederation,
-} from "@drfed/graphql/federation";
+} from "@drfed/federation/activity-delivery";
import { schema } from "@drfed/models";
import {
recordInbound,
diff --git a/packages/graphql/src/activity-delivery/entry.ts b/packages/graphql/src/activity-delivery/entry.ts
index 0ccdf5a..7eead9e 100644
--- a/packages/graphql/src/activity-delivery/entry.ts
+++ b/packages/graphql/src/activity-delivery/entry.ts
@@ -25,24 +25,6 @@ import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle";
import builder, { type DrFedObjectRef } from "../builder.ts";
-export { createKeyCache } from "./keycache.ts";
-export {
- classifyInbound,
- createInboundRecorder,
- parseBody,
- recordedHeaders,
-} from "./inbound.ts";
-export { describeActivity } from "./describe.ts";
-export {
- declaredKeyId,
- hasLdSignature,
- proofMethods,
- reportedVerdict,
-} from "./verification.ts";
-export { deliverActivity, groupRecipients } from "./outbound.ts";
-export { failureOf, queuedSettlements } from "./queue.ts";
-export type { ObservedKeyFetch, ObservedSpan } from "./tracking.ts";
-
const ActivityDeliveryDirection = builder.enumType(
"ActivityDeliveryDirection",
{
diff --git a/packages/graphql/src/activity-delivery/inbound.test.ts b/packages/graphql/src/activity-delivery/inbound.test.ts
index 19f83ba..a913499 100644
--- a/packages/graphql/src/activity-delivery/inbound.test.ts
+++ b/packages/graphql/src/activity-delivery/inbound.test.ts
@@ -19,6 +19,7 @@ import assert from "node:assert/strict";
import { it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
+import createFederation, { type TrackedFederation } from "@drfed/federation";
import {
type ObservedKeyFetch,
type ObservedSpan,
@@ -28,11 +29,8 @@ import {
parseBody,
recordedHeaders,
reportedVerdict,
-} from "@drfed/graphql/activity-delivery";
-import createFederation, {
- type TrackedFederation,
-} from "@drfed/graphql/federation";
-import { instanceUrl } from "@drfed/graphql/origin";
+} from "@drfed/federation/activity-delivery";
+import { instanceUrl } from "@drfed/federation/origin";
import { type Database, addActorCollectionItem, schema } from "@drfed/models";
import {
type FederationFetchOptions,
diff --git a/packages/graphql/src/activitypub-integration.test.ts b/packages/graphql/src/activitypub-integration.test.ts
new file mode 100644
index 0000000..6aa2bcd
--- /dev/null
+++ b/packages/graphql/src/activitypub-integration.test.ts
@@ -0,0 +1,140 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Keep dependent database writes and observations sequential.
+// oxlint-disable no-await-in-loop
+
+import assert from "node:assert/strict";
+
+import { createYogaServer } from "@drfed/graphql";
+import { schema } from "@drfed/models";
+import { PUBLIC_IRI } from "@drfed/models/resource";
+import { describe, it } from "@logtape/testing-node/autoload";
+import { eq } from "drizzle-orm";
+
+import { withTestHarness } from "./harness.test.ts";
+import {
+ globalId,
+ localActorId,
+ remoteActorId,
+ seedAuthenticatedLocalInstance,
+ seedLocalActor,
+ seedRemoteActor,
+} from "./seed.test.ts";
+
+describe("createYogaServer()", () => {
+ it("does not mutate the federation instance", async () => {
+ await withTestHarness(({ db, mailer, federation }) => {
+ assert.doesNotThrow(() =>
+ createYogaServer(db, federation, {
+ mailer,
+ loginOrigins: new Set(["https://drfed.test"]),
+ rootOrigin: new URL("https://drfed.test"),
+ }),
+ );
+ });
+ });
+});
+
+const actorIri = `https://test-instance.drfed.org/users/${localActorId}`;
+const accept = { accept: "application/activity+json" };
+
+const createMutation = `mutation Create($actor: ID!, $addressing: AddressingInput!) {
+ createObject(actor: $actor, contentHtml: "Hello
", addressing: $addressing) {
+ ... on Object { uuid }
+ ... on CreateObjectError { errorType: type message }
+ }
+}`;
+
+// Regression tests for
+// https://github.com/fedify-dev/drfed/pull/73#discussion_r4005163252:
+// The outbox counter must match its page predicate rather than a stored count.
+describe("ActivityPub outbox totalItems", () => {
+ for (const scenario of ["followers", "deleted"] as const) {
+ it(`does not count ${scenario} objects that outbox pages never return`, async () => {
+ await withTestHarness(async ({ db, federation, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const body = await (
+ await post(
+ {
+ query: createMutation,
+ variables: {
+ actor: globalId("Actor", localActorId),
+ addressing:
+ scenario === "followers"
+ ? { to: [`${actorIri}/followers`] }
+ : { to: [PUBLIC_IRI] },
+ },
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(body.errors, undefined);
+ assert.equal(body.data.createObject.errorType, undefined);
+ if (scenario === "deleted") {
+ await db
+ .update(schema.objects)
+ .set({ deleted: Temporal.Now.instant() })
+ .where(eq(schema.objects.id, body.data.createObject.uuid));
+ }
+ const fetchJson = async (iri: string) => {
+ const response = await federation.fetch(
+ new Request(iri, { headers: accept }),
+ { contextData: undefined },
+ );
+ assert.equal(response.status, 200);
+ return await response.json();
+ };
+ const page = await fetchJson(`${actorIri}/outbox?cursor=`);
+ assert.deepEqual(page.orderedItems ?? [], []);
+ const collection = await fetchJson(`${actorIri}/outbox`);
+ assert.equal(collection.totalItems, 0);
+ });
+ });
+ }
+});
+
+describe("stored collection membership", () => {
+ it("reads collection_items for GraphQL items", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedLocalActor(db);
+ await seedRemoteActor(db);
+ const reference = await db.query.actorCollectionReferences.findFirst({
+ where: { actorId: localActorId, role: "followers" },
+ });
+ assert.ok(reference);
+ await db.insert(schema.collectionItems).values({
+ collectionId: reference.collectionId,
+ itemId: remoteActorId,
+ position: 0,
+ });
+ const body = await (
+ await post({
+ query: `query($id: ID!) { node(id: $id) { ... on Actor { followers { resource { kind } totalCount items(first: 1) { edges { cursor node { kind iri detail { ... on Actor { username } } } } pageInfo { hasNextPage } } } } } }`,
+ variables: { id: globalId("Actor", localActorId) },
+ })
+ ).json();
+ assert.equal(body.errors, undefined);
+ assert.equal(body.data.node.followers.totalCount, 1);
+ assert.deepEqual(body.data.node.followers.items.edges[0].node, {
+ kind: "actor",
+ iri: "https://remote.example.com/users/bob",
+ detail: { username: "bob" },
+ });
+ });
+ });
+});
diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts
deleted file mode 100644
index 87f99c7..0000000
--- a/packages/graphql/src/federation.ts
+++ /dev/null
@@ -1,596 +0,0 @@
-// DrFed: A web-based platform for developing and debugging ActivityPub apps
-// Copyright (C) 2026 DrFed team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-import { type Database, schema } from "@drfed/models";
-import { PUBLIC_RESOURCE_ID } from "@drfed/models/resource";
-import type {
- ActivityPubObject,
- Actor,
- Addressing,
- ObjectType,
- Resource,
- StoredActivity,
-} from "@drfed/models/schema";
-import { type Uuid, validateUuid } from "@drfed/models/uuid";
-import {
- type Context,
- type FederationBuilder,
- type FederationOptions,
- createFederationBuilder,
-} from "@fedify/fedify";
-import {
- Object as APObject,
- Activity,
- Application,
- Article,
- Create,
- Endpoints,
- Group,
- Image,
- LanguageString,
- Note,
- Organization,
- Person,
- Service,
- Tombstone,
-} from "@fedify/vocab";
-import { getLogger } from "@logtape/logtape";
-import { metrics, trace } from "@opentelemetry/api";
-import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm";
-
-import { markQueued } from "./activity-delivery/outbound.ts";
-import {
- observeQueues,
- outboxQueue,
- reportOutboxError,
- reportPermanentFailure,
-} from "./activity-delivery/queue.ts";
-import { trackMetrics, trackSpans } from "./activity-delivery/telemetry.ts";
-import {
- type TrackedFederation,
- attachKv,
- markHandled,
- trackPublicKeys,
-} from "./activity-delivery/tracking.ts";
-import { canonicalizeAuthority } from "./origin.ts";
-
-export { createInboundRecorder } from "./activity-delivery/inbound.ts";
-export type { TrackedFederation } from "./activity-delivery/tracking.ts";
-export { deliverActivity } from "./activity-delivery/outbound.ts";
-
-/**
- * The vocabulary object types that DrFed serves as actors.
- */
-type ActorObject = Application | Group | Organization | Person | Service;
-
-type ActorProps = ConstructorParameters[0];
-
-const actorConstructors: Record<
- Actor["type"],
- (props: ActorProps) => ActorObject
-> = {
- Application: (props) => new Application(props),
- Group: (props) => new Group(props),
- Organization: (props) => new Organization(props),
- Person: (props) => new Person(props),
- Service: (props) => new Service(props),
-};
-
-async function findLocalActor(
- db: Database,
- ctx: Context,
- identifier: string,
-): Promise {
- if (!validateUuid(identifier)) return null;
- const actor = await db.query.actors.findFirst({
- where: {
- id: identifier as Uuid,
- localId: { isNotNull: true },
- instance: { host: canonicalizeAuthority(ctx.host) },
- },
- with: {
- resource: true,
- collectionReferences: {
- with: { collection: { with: { resource: true } } },
- },
- },
- });
- return actor ?? null;
-}
-
-async function findActiveActor(
- db: Database,
- ctx: Context,
- identifier: string,
-): Promise {
- const actor = await findLocalActor(db, ctx, identifier);
- return actor == null || actor.deleted != null ? null : actor;
-}
-
-/**
- * Creates a `FederationBuilder` with every ActivityPub dispatcher and
- * listener that DrFed serves registered on it. The registered paths define
- * the URI layout of the federated objects, which makes the object URI getters
- * (e.g. `Context.getActorUri()`) available once the builder is built.
- * @param db The database to resolve local actors from.
- * @returns A builder that has not been built yet.
- */
-export function buildFederation(db: Database): FederationBuilder {
- const builder = createFederationBuilder();
- builder
- .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
- const actor = await findLocalActor(db, ctx, identifier);
- if (actor == null) return null;
- // Deleted actors are served as `Tombstone`s (HTTP 410) so that remote
- // peers purge them instead of retrying on 404.
- if (actor.deleted != null) {
- return new Tombstone({ id: ctx.getActorUri(identifier) });
- }
- return toActorObject(ctx, identifier, actor);
- })
- // FIXME: https://github.com/fedify-dev/drfed/issues/87
- .setKeyPairsDispatcher(() => [])
- .mapHandle(async (ctx, username) => {
- const actor = await db.query.actors.findFirst({
- where: {
- username,
- localId: { isNotNull: true },
- instance: { host: canonicalizeAuthority(ctx.host) },
- deleted: { isNull: true },
- },
- });
- return actor?.id ?? null;
- });
- // FIXME: https://github.com/fedify-dev/drfed/issues/87
-
- builder
- .setInboxListeners("/users/{identifier}/inbox", "/inbox")
- // FIXME: https://github.com/fedify-dev/drfed/issues/88
- .on(Activity, (_ctx, activity) => {
- markHandled();
- logger.debug("Received an activity: {activity}", { activity });
- })
- .onError((_ctx, error) => {
- logger.error("An error occurred while processing an inbox: {error}", {
- error,
- });
- });
-
- builder.setObjectDispatcher(
- APObject,
- "/users/{identifier}/{id}",
- async (ctx, { identifier, id }) => {
- if (!validateUuid(identifier) || !validateUuid(id)) return null;
- const object = await db.query.objects.findFirst({
- where: {
- id,
- actorId: identifier,
- RAW: (table) => publicAddressing(table.id),
- actor: {
- localId: { isNotNull: true },
- deleted: { isNull: true },
- instance: { host: canonicalizeAuthority(ctx.host) },
- },
- },
- with: objectSelection,
- });
- if (object == null) return null;
- if (object.deleted != null) {
- return new Tombstone({
- id: new URL(object.resource.iri),
- deleted: object.deleted,
- });
- }
- return toObject(ctx, object);
- },
- );
-
- builder.setObjectDispatcher(
- Create,
- "/ap/creates/{id}",
- async (ctx, { id }) => {
- if (!validateUuid(id)) return null;
- const activity = await db.query.activities.findFirst({
- where: {
- id,
- actor: {
- localId: { isNotNull: true },
- deleted: { isNull: true },
- instance: { host: canonicalizeAuthority(ctx.host) },
- },
- RAW: (table) => servedActivity(table),
- },
- with: activitySelection,
- });
- return activity == null ? null : toCreate(ctx, activity);
- },
- );
-
- builder
- .setOutboxDispatcher(
- "/users/{identifier}/outbox",
- async (ctx, identifier, cursor) => {
- if ((await findActiveActor(db, ctx, identifier)) == null) return null;
- const boundary = parseOutboxCursor(cursor);
- if (boundary === false) return null;
- const rows = await db.query.activities.findMany({
- where: {
- actorId: identifier as Uuid,
- RAW: (table) =>
- and(
- servedActivity(table),
- boundary == null
- ? undefined
- : sql`(${table.published}, ${table.id}) <
- (${boundary.published}::timestamptz, ${boundary.id}::uuid)`,
- )!,
- },
- // Backfilled activity IDs are UUIDv7, so only publication time
- // determines chronology. Keep full database precision in cursors.
- extras: {
- cursorPublished: (table) =>
- sql`to_char(${table.published} AT TIME ZONE 'UTC',
- 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
- },
- orderBy: { published: "desc", id: "desc" },
- limit: OUTBOX_PAGE_SIZE + 1,
- with: activitySelection,
- });
- const page = rows.slice(0, OUTBOX_PAGE_SIZE);
- return {
- items: page.map((object) => toCreate(ctx, object)),
- nextCursor:
- rows.length > OUTBOX_PAGE_SIZE
- ? `${page.at(-1)!.cursorPublished}|${page.at(-1)!.id}`
- : null,
- };
- },
- )
- .setFirstCursor(async (ctx, identifier) =>
- (await findActiveActor(db, ctx, identifier)) == null ? null : "",
- )
- .setCounter(async (ctx, identifier) => {
- const actor = await findActiveActor(db, ctx, identifier);
- return actor == null
- ? null
- : db.$count(
- schema.activities,
- and(
- eq(schema.activities.actorId, actor.id),
- servedActivity(schema.activities),
- ),
- );
- });
-
- builder
- .setFollowersDispatcher(
- "/users/{identifier}/followers",
- async (ctx, identifier) => {
- const collection = await actorCollection(
- db,
- ctx,
- identifier,
- "followers",
- );
- if (collection == null) return null;
- // FollowersDispatcher requires actor inboxes for future delivery fan-out.
- return {
- items: collection.items.map(({ item }) => ({
- id: new URL(item.iri),
- inboxId: item.actor == null ? null : new URL(item.actor.inboxUrl),
- })),
- };
- },
- )
- .setCounter(
- async (ctx, identifier) =>
- (await actorCollection(db, ctx, identifier, "followers"))?.items
- .length ?? null,
- );
- builder
- .setFollowingDispatcher(
- "/users/{identifier}/following",
- async (ctx, identifier) => {
- const collection = await actorCollection(
- db,
- ctx,
- identifier,
- "following",
- );
- return collection == null
- ? null
- : { items: collection.items.map(({ item }) => new URL(item.iri)) };
- },
- )
- .setCounter(
- async (ctx, identifier) =>
- (await actorCollection(db, ctx, identifier, "following"))?.items
- .length ?? null,
- );
- builder.setFeaturedDispatcher(
- "/users/{identifier}/featured",
- async (ctx, identifier) => {
- const collection = await actorCollection(db, ctx, identifier, "featured");
- return collection == null
- ? null
- : {
- items: collection.items.map(
- ({ item }) => new APObject({ id: new URL(item.iri) }),
- ),
- };
- },
- );
- builder.setOutboxPermanentFailureHandler(reportPermanentFailure);
- return builder;
-}
-
-/**
- * Creates a `Federation` instance with every DrFed dispatcher registered.
- * Every registration happens on a fresh builder inside this function, so the
- * returned instance is complete and must not be mutated further.
- * The queues, if any, are observed so that each delivery attempt settles its
- * outbound delivery and each queued inbox listener run its inbound delivery. The public-key cache and the spans and measurements
- * Fedify reports are tracked so that `createInboundRecorder()` sees how each
- * inbox request was verified, and with which key.
- * @param db The database to resolve local actors from.
- * @param options Options for the underlying Fedify `Federation`, such as
- * the `kv` store.
- * @returns The built `Federation` instance.
- */
-export default async function createFederation(
- db: Database,
- options: FederationOptions,
-): Promise {
- const federation = await buildFederation(db).build({
- ...options,
- kv: trackPublicKeys(
- options.kv,
- options.kvPrefixes?.publicKey ?? ["_fedify", "publicKey"],
- ),
- tracerProvider: trackSpans(
- options.tracerProvider ?? trace.getTracerProvider(),
- ),
- meterProvider: trackMetrics(
- options.meterProvider ?? metrics.getMeterProvider(),
- ),
- ...(options.queue == null
- ? {}
- : { queue: observeQueues(db, options.kv, options.queue) }),
- async onOutboxError(error, activity) {
- await reportOutboxError(error, activity);
- await options.onOutboxError?.(error, activity);
- },
- });
- if (outboxQueue(options.queue) != null) markQueued(federation);
- attachKv(federation, options.kv);
- return federation as TrackedFederation;
-}
-
-// Whether a sanction is *currently* active is always determined by comparing
-// against the current time (lazy expiry; no cron); see the actors table.
-function isSuspended({ suspended, suspendedUntil }: Actor): boolean {
- const now = Temporal.Now.instant();
- return (
- suspended != null &&
- Temporal.Instant.compare(suspended, now) <= 0 &&
- (suspendedUntil == null ||
- Temporal.Instant.compare(suspendedUntil, now) > 0)
- );
-}
-
-function toActorObject(
- ctx: Context,
- identifier: string,
- actor: StoredActor,
-): ActorObject {
- return actorConstructors[actor.type]({
- id: new URL(actor.resource.iri),
- preferredUsername: actor.username,
- name: actor.name,
- summary: actor.bioHtml,
- url: actor.profileUrl == null ? null : new URL(actor.profileUrl),
- icon:
- actor.avatarUrl == null
- ? null
- : new Image({ url: new URL(actor.avatarUrl) }),
- image:
- actor.headerUrl == null
- ? null
- : new Image({ url: new URL(actor.headerUrl) }),
- manuallyApprovesFollowers: !actor.automaticallyApprovesFollowers,
- sensitive: actor.sensitive,
- suspended: isSuspended(actor),
- aliases: actor.aliases.map((alias) => new URL(alias)),
- inbox: new URL(actor.inboxUrl),
- outbox: collectionIri(actor, "outbox"),
- followers: collectionIri(actor, "followers"),
- following: collectionIri(actor, "following"),
- featured: collectionIri(actor, "featured"),
- endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
- });
-}
-
-const logger = getLogger(["drfed", "graphql", "federation"]);
-
-/**
- * Parse an opaque boundary without rounding database microseconds.
- * @returns The boundary, null for the first page, or false for invalid input.
- */
-function parseOutboxCursor(
- cursor: string | null,
-): { published: string; id: string } | null | false {
- if (cursor == null || cursor === "") return null;
- const [published, id, extra] = cursor.split("|");
- if (
- published == null ||
- published.startsWith("0000-") ||
- !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/u.test(published) ||
- id == null ||
- !validateUuid(id) ||
- extra != null
- ) {
- return false;
- }
- try {
- Temporal.Instant.from(published);
- return { published, id };
- } catch {
- return false;
- }
-}
-
-const OUTBOX_PAGE_SIZE = 20;
-type ObjectProps = ConstructorParameters[0];
-const objectConstructors: Record APObject> =
- {
- Article: (props) => new Article(props),
- Note: (props) => new Note(props),
- };
-
-type StoredAddressing = Addressing & { targetResource: Resource };
-type StoredActor = Actor & {
- resource: Resource;
- collectionReferences: (typeof schema.actorCollectionReferences.$inferSelect & {
- collection: typeof schema.collections.$inferSelect & { resource: Resource };
- })[];
-};
-export const objectSelection = {
- resource: true,
- actor: { with: { resource: true } },
- addressing: { with: { targetResource: true }, orderBy: { position: "asc" } },
-} as const;
-export const activitySelection = {
- resource: true,
- actor: { with: { resource: true } },
- object: true,
- addressing: { with: { targetResource: true }, orderBy: { position: "asc" } },
-} as const;
-type StoredObject = ActivityPubObject & {
- resource: Resource;
- actor: Actor & { resource: Resource };
- addressing: StoredAddressing[];
-};
-type StoredCreate = StoredActivity & {
- resource: Resource;
- actor: Actor & { resource: Resource };
- object: Resource | null;
- addressing: StoredAddressing[];
-};
-
-function recipients(rows: readonly StoredAddressing[]): {
- tos: URL[];
- ccs: URL[];
- audiences: URL[];
-} {
- const values = (property: string): URL[] =>
- rows
- .filter((entry) => entry.property === property)
- .toSorted((left, right) => left.position - right.position)
- .map((entry) => new URL(entry.targetResource.iri));
- return {
- tos: values("to"),
- ccs: values("cc"),
- audiences: values("audience"),
- };
-}
-
-/**
- * Shared Public predicate for object, activity, outbox page and counter.
- * @returns An EXISTS predicate matching explicit Public addressing.
- */
-function publicAddressing(sourceId: SQLWrapper): SQL {
- return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
-}
-function servedActivity(table: {
- id: SQLWrapper;
- objectId: SQLWrapper;
- type: SQLWrapper;
-}): SQL {
- return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`;
-}
-function collectionIri(actor: StoredActor, role: string): URL | null {
- const reference = actor.collectionReferences.find(
- (entry) => entry.role === role,
- );
- return reference == null ? null : new URL(reference.collection.resource.iri);
-}
-async function actorCollection(
- db: Database,
- ctx: Context,
- identifier: string,
- role: "followers" | "following" | "featured",
-) {
- const actor = await findActiveActor(db, ctx, identifier);
- if (actor == null) return null;
- const reference = await db.query.actorCollectionReferences.findFirst({
- where: { actorId: actor.id, role },
- with: {
- collection: {
- with: {
- items: {
- orderBy: { position: "asc", itemId: "asc" },
- with: { item: { with: { actor: true } } },
- },
- },
- },
- },
- });
- return reference?.collection ?? { items: [] };
-}
-
-/**
- * Serializes stored object addressing; blind recipients stay in the database.
- * @returns The vocabulary object without blind recipients.
- */
-export function toObject(
- _ctx: Context,
- object: StoredObject,
-): APObject {
- return objectConstructors[object.type]({
- id: new URL(object.resource.iri),
- attribution: new URL(object.actor.resource.iri),
- contents: [
- object.contentHtml,
- ...(object.language == null
- ? []
- : [new LanguageString(object.contentHtml, object.language)]),
- ],
- name: object.name,
- summary: object.summary,
- sensitive: object.sensitive,
- published: object.published,
- updated: object.updated,
- url: object.url == null ? null : new URL(object.url),
- ...recipients(object.addressing),
- });
-}
-
-/**
- * Serializes a persisted Create activity, retaining its own IRI and addressing.
- * @returns The vocabulary activity without blind recipients.
- */
-export function toCreate(
- _ctx: Context,
- activity: StoredCreate,
-): Create {
- return new Create({
- id: new URL(activity.resource.iri),
- actor: new URL(activity.actor.resource.iri),
- ...recipients(activity.addressing),
- object: activity.object == null ? null : new URL(activity.object.iri),
- published: activity.published,
- });
-}
diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts
index 7d2df75..bfe919c 100644
--- a/packages/graphql/src/harness.test.ts
+++ b/packages/graphql/src/harness.test.ts
@@ -13,9 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import createFederation from "@drfed/federation";
import { createYogaServer } from "@drfed/graphql";
import type { ServerContext, UserContext } from "@drfed/graphql/builder";
-import createFederation from "@drfed/graphql/federation";
import { type Database, migrate, relations, schema } from "@drfed/models";
import { PGlite } from "@electric-sql/pglite";
import { type Federation, MemoryKvStore } from "@fedify/fedify";
diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts
index b380dac..060a796 100644
--- a/packages/graphql/src/index.ts
+++ b/packages/graphql/src/index.ts
@@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import { canonicalHostname } from "@drfed/federation/origin";
import type { Database } from "@drfed/models";
import type { Federation } from "@fedify/fedify";
import { getYogaLogger } from "@logtape/graphql-yoga";
@@ -28,7 +29,6 @@ import {
import { hashSecret } from "./auth/hash.ts";
import type { ServerContext, UserContext } from "./builder.ts";
-import { canonicalHostname } from "./origin.ts";
import { schema } from "./schema.ts";
/**
* Options for Yoga server.
@@ -65,7 +65,7 @@ export interface YogaServerOptions {
* @param {Database} db The database instance.
* @param {Federation} federation The federation instance. It must
* already have every dispatcher registered (see `createFederation()`
- * in `@drfed/graphql/federation`); this function only stores it in
+ * in `@drfed/federation`); this function only stores it in
* the resolver context and never mutates it, so the same instance can
* be shared by several servers.
* @param {YogaServerOptions} rawOptions Options for server.
diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts
index 8c92d66..c6fd7f3 100644
--- a/packages/graphql/src/instance.ts
+++ b/packages/graphql/src/instance.ts
@@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import { instanceHost } from "@drfed/federation/origin";
import { schema } from "@drfed/models";
import { isValidSlug } from "@drfed/models/slug";
import { uuidV7 as uuid } from "@drfed/models/uuid";
@@ -21,7 +22,6 @@ import { DrizzleQueryError } from "drizzle-orm";
import { eq } from "drizzle-orm/sql/expressions";
import builder, { type DrFedObjectRef } from "./builder.ts";
-import { instanceHost } from "./origin.ts";
const InstanceRef = builder.drizzleNode("instances", {
name: "Instance",
diff --git a/packages/graphql/src/object.ts b/packages/graphql/src/object.ts
index 2444b62..63b7310 100644
--- a/packages/graphql/src/object.ts
+++ b/packages/graphql/src/object.ts
@@ -14,6 +14,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import {
+ activitySelection,
+ objectSelection,
+ toCreate,
+ toObject,
+} from "@drfed/federation/object";
import {
addActorCollectionItem,
lockActorCollection,
@@ -29,12 +35,6 @@ import { and, eq, gt, isNotNull, isNull } from "drizzle-orm";
import { Actor } from "./actor.ts";
import builder, { type DrFedObjectRef } from "./builder.ts";
-import {
- activitySelection,
- objectSelection,
- toCreate,
- toObject,
-} from "./federation.ts";
import {
Activity,
ActivityType,
diff --git a/packages/graphql/src/resource.ts b/packages/graphql/src/resource.ts
index b73a89c..167b402 100644
--- a/packages/graphql/src/resource.ts
+++ b/packages/graphql/src/resource.ts
@@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import { activitySelection, objectSelection } from "@drfed/federation/object";
import { type Database, schema } from "@drfed/models";
import type { Resource as ResourceRow } from "@drfed/models/schema";
import { type Uuid, validateUuid } from "@drfed/models/uuid";
@@ -28,7 +29,6 @@ import {
classifyMastodon,
classifyMisskey,
} from "./classification.ts";
-import { activitySelection, objectSelection } from "./federation.ts";
export const ResourceKind = builder.enumType("ResourceKind", {
values: schema.resourceKindEnum.enumValues,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17278d5..013c1b0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -104,6 +104,9 @@ importers:
packages/drfed:
dependencies:
+ '@drfed/federation':
+ specifier: workspace:*
+ version: link:../federation
'@drfed/graphql':
specifier: workspace:*
version: link:../graphql
@@ -169,8 +172,48 @@ importers:
specifier: 'catalog:'
version: 7.0.2
+ packages/federation:
+ dependencies:
+ '@drfed/models':
+ specifier: workspace:*
+ version: link:../models
+ '@fedify/fedify':
+ specifier: 'catalog:'
+ version: 2.4.0
+ '@fedify/vocab':
+ specifier: 'catalog:'
+ version: 2.4.0
+ '@logtape/logtape':
+ specifier: 'catalog:'
+ version: 2.3.0-dev.840
+ '@opentelemetry/api':
+ specifier: ^1.9.1
+ version: 1.9.1
+ drizzle-orm:
+ specifier: 'catalog:'
+ version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))
+ devDependencies:
+ '@electric-sql/pglite':
+ specifier: 'catalog:'
+ version: 0.5.3
+ '@logtape/testing-node':
+ specifier: 'catalog:'
+ version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 26.0.0
+ tsdown:
+ specifier: 'catalog:'
+ version: 0.22.14(typescript@7.0.2)
+ typescript:
+ specifier: 'catalog:'
+ version: 7.0.2
+
packages/graphql:
dependencies:
+ '@drfed/federation':
+ specifier: workspace:*
+ version: link:../federation
'@drfed/models':
specifier: workspace:*
version: link:../models
@@ -192,9 +235,6 @@ importers:
'@logtape/logtape':
specifier: 'catalog:'
version: 2.3.0-dev.840
- '@opentelemetry/api':
- specifier: ^1.9.1
- version: 1.9.1
'@pothos/core':
specifier: ^4.13.0
version: 4.13.0(graphql@16.14.2)
@@ -301,10 +341,10 @@ importers:
version: 1.0.0(solid-js@1.9.14)
'@solidjs/start':
specifier: ^2.0.0
- version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+ version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
nitro:
specifier: 3.0.260610-beta
- version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+ version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
relay-runtime:
specifier: ^21.0.1
version: 21.0.1
@@ -332,7 +372,7 @@ importers:
version: 20.1.1
eslint-plugin-solid:
specifier: ^0.14.5
- version: 0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)
+ version: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)
relay-compiler:
specifier: ^21.0.1
version: 21.0.1
@@ -3924,20 +3964,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
- '@babel/core@7.29.7':
+ '@babel/core@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.29.7
- '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
- '@babel/traverse': 7.29.8
+ '@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3966,19 +4006,19 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
- '@babel/helper-module-imports@7.29.7':
+ '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
- '@babel/traverse': 7.29.8
+ '@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.7
- '@babel/helper-module-imports': 7.29.7
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
- '@babel/traverse': 7.29.8
+ '@babel/traverse': 7.29.8(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3999,9 +4039,9 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
- '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
+ '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.7
+ '@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
@@ -4012,7 +4052,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
- '@babel/traverse@7.29.8':
+ '@babel/traverse@7.29.8(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
@@ -4020,7 +4060,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4262,17 +4302,17 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2':
+ '@eslint/config-array@0.21.2(supports-color@7.2.0)':
dependencies:
'@eslint/object-schema': 2.1.7
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4285,10 +4325,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5':
+ '@eslint/eslintrc@3.3.5(supports-color@7.2.0)':
dependencies:
ajv: 6.15.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -5052,10 +5092,10 @@ snapshots:
dependencies:
solid-js: 1.9.14
- '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))':
+ '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))':
dependencies:
- '@babel/core': 7.29.7
- '@babel/traverse': 7.29.8
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@solidjs/meta': 0.29.4(solid-js@1.9.14)
'@types/babel__traverse': 7.28.0
@@ -5079,7 +5119,7 @@ snapshots:
srvx: 0.12.5
terracotta: 1.1.1(solid-js@1.9.14)
vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
- vite-plugin-solid: 2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+ vite-plugin-solid: 2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
optionalDependencies:
'@solidjs/router': 1.0.0(solid-js@1.9.14)
transitivePeerDependencies:
@@ -5154,11 +5194,11 @@ snapshots:
'@types/unist@3.0.3': {}
- '@typescript-eslint/project-service@8.62.1(typescript@7.0.2)':
+ '@typescript-eslint/project-service@8.62.1(supports-color@7.2.0)(typescript@7.0.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2)
'@typescript-eslint/types': 8.62.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -5174,13 +5214,13 @@ snapshots:
'@typescript-eslint/types@8.62.1': {}
- '@typescript-eslint/typescript-estree@8.62.1(typescript@7.0.2)':
+ '@typescript-eslint/typescript-estree@8.62.1(supports-color@7.2.0)(typescript@7.0.2)':
dependencies:
- '@typescript-eslint/project-service': 8.62.1(typescript@7.0.2)
+ '@typescript-eslint/project-service': 8.62.1(supports-color@7.2.0)(typescript@7.0.2)
'@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2)
'@typescript-eslint/types': 8.62.1
'@typescript-eslint/visitor-keys': 8.62.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
@@ -5189,13 +5229,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)':
+ '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.62.1
'@typescript-eslint/types': 8.62.1
- '@typescript-eslint/typescript-estree': 8.62.1(typescript@7.0.2)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/typescript-estree': 8.62.1(supports-color@7.2.0)(typescript@7.0.2)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -5416,19 +5456,19 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
- babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7):
+ babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@7.2.0)):
dependencies:
- '@babel/core': 7.29.7
+ '@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.18.6
- '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
'@babel/types': 7.29.8
html-entities: 2.3.3
parse5: 7.3.0
- babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14):
+ babel-preset-solid@1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14):
dependencies:
- '@babel/core': 7.29.7
- babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7)
+ '@babel/core': 7.29.7(supports-color@7.2.0)
+ babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7(supports-color@7.2.0))
optionalDependencies:
solid-js: 1.9.14
@@ -5552,9 +5592,11 @@ snapshots:
'@electric-sql/pglite': 0.5.3
drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))
- debug@4.4.3:
+ debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
deep-is@0.1.4: {}
@@ -5683,10 +5725,10 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2):
+ eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2):
dependencies:
- '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
estraverse: 5.3.0
is-html: 2.0.0
kebab-case: 1.0.2
@@ -5707,14 +5749,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.7.0):
+ eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2
+ '@eslint/config-array': 0.21.2(supports-color@7.2.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5
+ '@eslint/eslintrc': 3.3.5(supports-color@7.2.0)
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -5724,7 +5766,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -5963,11 +6005,11 @@ snapshots:
dependencies:
loose-envify: 1.4.0
- ioredis@5.11.1:
+ ioredis@5.11.1(supports-color@7.2.0):
dependencies:
'@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
denque: 2.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
@@ -6185,7 +6227,7 @@ snapshots:
nf3@0.3.23: {}
- nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
+ nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
consola: 3.4.2
crossws: 0.4.10(srvx@0.11.16)
@@ -6200,7 +6242,7 @@ snapshots:
rolldown: 1.2.0
srvx: 0.11.16
unenv: 2.0.0-rc.24
- unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3)
+ unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3)
optionalDependencies:
dotenv: 17.4.2
giget: 3.3.0
@@ -6641,10 +6683,10 @@ snapshots:
'@corvu/utils': 0.4.2(solid-js@1.9.14)
solid-js: 1.9.14
- solid-refresh@0.6.3(solid-js@1.9.14):
+ solid-refresh@0.6.3(solid-js@1.9.14)(supports-color@7.2.0):
dependencies:
'@babel/generator': 7.29.8
- '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/types': 7.29.8
solid-js: 1.9.14
transitivePeerDependencies:
@@ -6855,11 +6897,11 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
- unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3):
+ unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3):
optionalDependencies:
chokidar: 5.0.0
db0: 0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))
- ioredis: 5.11.1
+ ioredis: 5.11.1(supports-color@7.2.0)
lru-cache: 11.5.1
ofetch: 2.0.0-alpha.3
@@ -6911,14 +6953,14 @@ snapshots:
transitivePeerDependencies:
- typescript
- vite-plugin-solid@2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
+ vite-plugin-solid@2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
- '@babel/core': 7.29.7
+ '@babel/core': 7.29.7(supports-color@7.2.0)
'@types/babel__core': 7.20.5
- babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14)
+ babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14)
merge-anything: 5.1.7
solid-js: 1.9.14
- solid-refresh: 0.6.3(solid-js@1.9.14)
+ solid-refresh: 0.6.3(solid-js@1.9.14)(supports-color@7.2.0)
vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
vitefu: 1.1.3(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
transitivePeerDependencies:
diff --git a/tsconfig.federation.json b/tsconfig.federation.json
new file mode 100644
index 0000000..39318a0
--- /dev/null
+++ b/tsconfig.federation.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./packages/federation/tsconfig.json",
+ "compilerOptions": {
+ "typeRoots": ["./packages/federation/node_modules/@types"]
+ },
+ "include": ["packages/federation/src/**/*.ts"]
+}