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"] +}