From bb461ee28c3f7cf669ae1ec5570c7ca1e4551eca Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 17:01:06 +0900 Subject: [PATCH] Move federation logic into @drfed/federation DrFed's ActivityPub dispatchers, inbox listeners, vocabulary serialization, and activity delivery tracking lived in @drfed/graphql, so serving ActivityPub required the GraphQL package even though it never needs a schema. They now live in a new @drfed/federation package that depends on @drfed/models but not on GraphQL, following the structure of Hackers' Pub's federation package while keeping DrFed's fresh-builder factory. The new package exposes four subpaths: - @drfed/federation: buildFederation(db) and the default createFederation(db, options), unchanged in signature and behavior, plus createInboundRecorder(), deliverActivity(), and the TrackedFederation type. buildFederation() still creates a fresh builder on every call and passes it to register functions in actor.ts, object-dispatchers.ts, collection.ts, and inbox.ts, in the same registration order as before. Nothing registers on a module-level builder. - @drfed/federation/activity-delivery: the delivery recording functions that @drfed/graphql/activity-delivery used to re-export. - @drfed/federation/object: objectSelection, activitySelection, toObject(), toCreate(), and the StoredObject/StoredCreate input types. GraphQL mutations keep using these to build stored JSON-LD documents, so stored documents and ActivityPub responses share one set of serialization rules. - @drfed/federation/origin: the instance host rules, moved verbatim from packages/graphql/src/origin.ts so that server routing and federation lookups cannot disagree about instance hosts. The activity delivery runtime from #101 had to move along with the dispatchers: createFederation() wraps the KV store, tracer, meter, and queues with it, the inbox listener calls markHandled(), and the builder registers its permanent-failure handler. Its nine GraphQL-free modules moved unchanged into src/activity-delivery/; only the Pothos types in packages/graphql/src/activity-delivery/entry.ts stay behind. The Public addressing and served-activity predicates moved to an internal visibility.ts shared by the Create dispatcher, outbox pages, and outbox counter. Registration helpers stay internal and are not exported. @drfed/graphql drops its ./federation and ./origin subpaths, and ./activity-delivery no longer re-exports runtime functions, without compatibility shims, since no version has been released and every consumer is in this repository. Its README lists the replacement imports. @drfed/graphql and @drfed/drfed import from @drfed/federation, and mise.toml's build:server task builds the new package. No tsconfig path alias is added for @drfed/federation: mapping its root to source while its subpaths resolve to dist/ would give TrackedFederation two incompatible unique-symbol brands. The intended observable changes are the logger categories, from ["drfed", "graphql", "federation"] to ["drfed", "federation"] and from ["drfed", "graphql", "activity-delivery"] to ["drfed", "federation", "activity-delivery"]. Tests that need only a database and a federation moved to the new package with their own temporary-database harness, a copy of the database-only seed fixtures, and a copy of the remote inbox helper; this includes the outbound and queue delivery tests. The GraphQL-dependent ones remain in @drfed/graphql, including activitypub-integration.test.ts and the inbound delivery tests; the test that checked collection items over both ActivityPub and GraphQL was split between the two. A new regression test builds federations from two databases holding the same actor identifier and checks that each one serves its own database's actor, which a shared builder would break. The new package adds DOM to its TypeScript lib so the moved tests keep reading untyped JSON response bodies, as they did under @drfed/graphql, which gets DOM typings transitively through graphql-yoga. Verified with a clean `mise run build`, `mise run check`, and `mise run test`; with `mise run dev`; and by installing the packed tarballs of all four server packages outside the repository, starting the installed drfed-server on an empty PGlite directory, seeding an instance, checking GraphQL, actor, object, Create, outbox, and WebFinger responses, checking that an unsigned inbox POST is recorded as an unverified inbound delivery, and type-checking a strict TypeScript consumer of every @drfed/federation subpath. Closes https://github.com/fedify-dev/drfed/issues/102 AI provenance: Claude Code (Claude Opus 5.5) read the issue and the Hackers' Pub reference code, drafted the design and refactoring plan, implemented the move, resolved the rebase onto the activity delivery work by moving its runtime into the new package, wrote the new tests and documentation, and ran the build, check, test, dev-server, and packed-installation verification. Codex (GPT-6 Astra) reviewed the plan over three rounds; its feedback separated the public serializer subpath from internal dispatcher registration and shared predicates, added the cross-database isolation test, and extended the packed-package verification. Codex (GPT-6 Astra) and Claude Code (Claude Fable 5.1) then reviewed the change before and after the rebase and reported no actionable findings, so neither review changed the code. Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra --- CONTRIBUTING.md | 75 ++- mise.toml | 2 +- packages/drfed/README.md | 5 +- packages/drfed/package.json | 1 + packages/drfed/src/index.ts | 4 +- packages/drfed/src/serving.test.ts | 4 +- packages/drfed/src/serving.ts | 2 +- packages/federation/README.md | 67 ++ packages/federation/package.json | 98 +++ packages/federation/src/activity-delivery.ts | 40 ++ .../src/activity-delivery/addressing.ts | 0 .../src/activity-delivery/describe.ts | 0 .../src/activity-delivery/inbound.ts | 2 +- .../src/activity-delivery/keycache.ts | 0 .../src/activity-delivery/outbound.test.ts | 4 +- .../src/activity-delivery/outbound.ts | 2 +- .../src/activity-delivery/queue.test.ts | 4 +- .../src/activity-delivery/queue.ts | 2 +- .../src/activity-delivery/remote.test.ts | 75 +++ .../src/activity-delivery/telemetry.ts | 0 .../src/activity-delivery/tracking.ts | 0 .../src/activity-delivery/verification.ts | 2 +- packages/federation/src/actor.ts | 183 ++++++ packages/federation/src/collection.ts | 204 ++++++ .../src/federation.test.ts | 179 ++---- packages/federation/src/harness.test.ts | 94 +++ packages/federation/src/inbox.ts | 44 ++ packages/federation/src/index.ts | 106 ++++ packages/federation/src/object-dispatchers.ts | 89 +++ packages/federation/src/object.ts | 134 ++++ .../src/origin.test.ts | 2 +- .../{graphql => federation}/src/origin.ts | 0 packages/federation/src/seed.test.ts | 210 ++++++ packages/federation/src/visibility.ts | 42 ++ packages/federation/tsconfig.json | 11 + packages/graphql/README.md | 74 ++- packages/graphql/package.json | 12 +- .../graphql/src/activity-delivery.test.ts | 6 +- .../graphql/src/activity-delivery/entry.ts | 18 - .../src/activity-delivery/inbound.test.ts | 8 +- .../src/activitypub-integration.test.ts | 140 ++++ packages/graphql/src/federation.ts | 596 ------------------ packages/graphql/src/harness.test.ts | 2 +- packages/graphql/src/index.ts | 4 +- packages/graphql/src/instance.ts | 2 +- packages/graphql/src/object.ts | 12 +- packages/graphql/src/resource.ts | 2 +- pnpm-lock.yaml | 174 +++-- tsconfig.federation.json | 7 + 49 files changed, 1848 insertions(+), 896 deletions(-) create mode 100644 packages/federation/README.md create mode 100644 packages/federation/package.json create mode 100644 packages/federation/src/activity-delivery.ts rename packages/{graphql => federation}/src/activity-delivery/addressing.ts (100%) rename packages/{graphql => federation}/src/activity-delivery/describe.ts (100%) rename packages/{graphql => federation}/src/activity-delivery/inbound.ts (99%) rename packages/{graphql => federation}/src/activity-delivery/keycache.ts (100%) rename packages/{graphql => federation}/src/activity-delivery/outbound.test.ts (99%) rename packages/{graphql => federation}/src/activity-delivery/outbound.ts (99%) rename packages/{graphql => federation}/src/activity-delivery/queue.test.ts (99%) rename packages/{graphql => federation}/src/activity-delivery/queue.ts (99%) create mode 100644 packages/federation/src/activity-delivery/remote.test.ts rename packages/{graphql => federation}/src/activity-delivery/telemetry.ts (100%) rename packages/{graphql => federation}/src/activity-delivery/tracking.ts (100%) rename packages/{graphql => federation}/src/activity-delivery/verification.ts (99%) create mode 100644 packages/federation/src/actor.ts create mode 100644 packages/federation/src/collection.ts rename packages/{graphql => federation}/src/federation.test.ts (85%) create mode 100644 packages/federation/src/harness.test.ts create mode 100644 packages/federation/src/inbox.ts create mode 100644 packages/federation/src/index.ts create mode 100644 packages/federation/src/object-dispatchers.ts create mode 100644 packages/federation/src/object.ts rename packages/{graphql => federation}/src/origin.test.ts (99%) rename packages/{graphql => federation}/src/origin.ts (100%) create mode 100644 packages/federation/src/seed.test.ts create mode 100644 packages/federation/src/visibility.ts create mode 100644 packages/federation/tsconfig.json create mode 100644 packages/graphql/src/activitypub-integration.test.ts delete mode 100644 packages/graphql/src/federation.ts create mode 100644 tsconfig.federation.json 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"] +}