diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1ee30f7..06dcffc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -362,6 +362,20 @@ 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_logs`, 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 log 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 log. Log contents are private
+to local instance members and administrators, including Relay node lookups.
+
CLI and server changes
----------------------
diff --git a/mise.toml b/mise.toml
index ce2f680..4a67d40 100644
--- a/mise.toml
+++ b/mise.toml
@@ -98,7 +98,7 @@ node scripts/add-license/main.mts
[tasks.test]
description = "Run tests"
-depends = ["build"]
+depends = ["build:server"]
usage = '''
flag "-d --debug" help="Run tests in debug mode"
'''
diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index 6d6a572..8a7d90e 100644
--- a/packages/drfed/src/index.ts
+++ b/packages/drfed/src/index.ts
@@ -18,7 +18,9 @@ import { writeFile } from "node:fs/promises";
import process from "node:process";
import { createYogaServer } from "@drfed/graphql";
-import createFederation from "@drfed/graphql/federation";
+import createFederation, {
+ createInboundRecorder,
+} from "@drfed/graphql/federation";
import { schema } from "@drfed/graphql/schema";
import { migrate } from "@drfed/models";
import { PgliteKvStore } from "@fedify/pglite";
@@ -48,7 +50,10 @@ async function runServer(options: ServerOptions) {
"driver" in credentials
? new PgliteKvStore(credentials.client)
: new PostgresKvStore(credentials.client);
- const federation = await createFederation(options.drizzle.db, { kv });
+ const federation = await createFederation(options.drizzle.db, {
+ kv,
+ allowPrivateAddress: true,
+ });
const { emailFrom, mailer, rootOrigin, loginOrigins } = options;
const yogaServer = createYogaServer(options.drizzle.db, federation, {
@@ -60,7 +65,11 @@ async function runServer(options: ServerOptions) {
await warnAboutStrandedInstances(options.drizzle.db, rootOrigin);
const server = serve({
fetch: createFetchHandler({
- federation,
+ federation: createInboundRecorder({
+ db: options.drizzle.db,
+ federation,
+ rootOrigin,
+ }),
rootOrigin,
serveControlSurface: yogaServer.fetch,
}),
diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts
index fea5552..696e159 100644
--- a/packages/drfed/src/serving.test.ts
+++ b/packages/drfed/src/serving.test.ts
@@ -21,9 +21,13 @@ import {
findStrandedInstances,
warnAboutStrandedInstances,
} from "@drfed/drfed/serving";
+import createFederation, {
+ createInboundRecorder,
+} from "@drfed/graphql/federation";
import { migrate, relations, schema } from "@drfed/models";
import { uuidV7 as uuid } from "@drfed/models/uuid";
import { PGlite } from "@electric-sql/pglite";
+import { MemoryKvStore } from "@fedify/fedify";
import { describe, it } from "@logtape/testing-node/autoload";
import { drizzle } from "drizzle-orm/pglite";
@@ -241,3 +245,54 @@ describe("findStrandedInstances()", () => {
}
});
});
+
+it("records inbox requests only on the instance surface", async () => {
+ const client = new PGlite();
+ try {
+ await migrate({ credentials: { driver: "pglite", client } });
+ const db = drizzle({ client, relations, schema });
+ const localId = uuid();
+ await db.insert(schema.localInstances).values({
+ id: localId,
+ slug: "logs",
+ expires: Temporal.Now.instant().add({ hours: 24 }),
+ });
+ await db
+ .insert(schema.instances)
+ .values({ id: localId, localId, host: "logs.drfed.net" });
+ const kv = new MemoryKvStore();
+ const federation = await createFederation(db, { kv });
+ const fetch = createFetchHandler({
+ rootOrigin,
+ federation: createInboundRecorder({ db, federation, rootOrigin }),
+ serveControlSurface: () => new Response("control"),
+ });
+ const body = JSON.stringify({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ type: "Create",
+ actor: "https://remote.example/alice",
+ });
+ assert.equal(
+ (
+ await fetch(
+ new Request("https://logs.drfed.net/inbox", { method: "POST", body }),
+ )
+ ).status,
+ 401,
+ );
+ assert.equal(
+ (
+ await fetch(
+ new Request("https://drfed.net/inbox", { method: "POST", body }),
+ )
+ ).status,
+ 200,
+ );
+ const rows = await db.query.activityLogs.findMany();
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0]?.instanceId, localId);
+ assert.equal(rows[0]?.status, "unverified");
+ } finally {
+ await client.close();
+ }
+});
diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts
index 05ff70d..5f7c13c 100644
--- a/packages/drfed/src/serving.ts
+++ b/packages/drfed/src/serving.ts
@@ -19,8 +19,8 @@ import type { Database } from "@drfed/models";
import { getLogger } from "@logtape/logtape";
/**
- * The part of a Fedify `Federation` that the router needs, narrowed so that
- * the routing can be exercised without building one.
+ * The federation surface (including the inbound recorder) that the router
+ * needs, narrowed so that the routing can be exercised without building one.
*/
export interface FederationHandler {
fetch(
@@ -43,7 +43,7 @@ export interface FetchHandlerOptions {
readonly rootOrigin: URL;
/**
- * The ActivityPub surface, served on instance subdomains.
+ * The ActivityPub surface with inbox recording, served on instance subdomains.
*/
readonly federation: FederationHandler;
diff --git a/packages/graphql/README.md b/packages/graphql/README.md
index e6b42f4..357af9e 100644
--- a/packages/graphql/README.md
+++ b/packages/graphql/README.md
@@ -24,15 +24,16 @@ Usage
~~~~ ts
import { createYogaServer } from "@drfed/graphql";
-import createFederation from "@drfed/graphql/federation";
+import createFederation, { createInboundRecorder } from "@drfed/graphql/federation";
const federation = await createFederation(db, { kv });
+const recordedInbox = createInboundRecorder({ db, federation, rootOrigin });
const yoga = createYogaServer(db, federation, {
loginOrigins: new Set(["https://drfed.example.com"]),
});
serve({
fetch: (request) =>
- federation.fetch(request, { onNotFound: yoga.fetch, contextData: undefined }),
+ recordedInbox.fetch(request, { onNotFound: yoga.fetch, contextData: undefined }),
});
~~~~
@@ -41,3 +42,106 @@ 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.
+
+
+Activity logs
+-------------
+
+`Instance.activityLogs` and `Actor.activityLogs` expose delivery observations,
+newest first, with direction, status, and type filters. Only accepted local
+instance members and site administrators can read them, including through
+Relay node IDs. `Actor.activityLogs` lists the deliveries that arrived at the
+actor's inbox, that the actor sent, and that are addressed to the actor through
+any inbox, the shared one included. Addressing through a collection counts as
+far as DrFed has stored the collection's members.
+
+Wrap the federation HTTP surface with `createInboundRecorder`, passing a
+federation made by `createFederation` and the root origin. Every inbox `POST`
+is logged, whether or not its body is JSON; logging errors never replace
+federation responses. A request Fedify throws on is logged too, with the
+exception in `error` and no `statusCode`, and the exception is thrown again.
+An inbound log keeps:
+
+ - The request as received: `rawBody` (or `rawBodyBase64` when the body is not
+ valid UTF-8), `requestHeaders`, and `requestUrl`. `payload` is the body
+ parsed as JSON for querying, and is `null` when it does not parse or
+ holds U+0000 or an unpaired surrogate, which PostgreSQL cannot store.
+ `cookie` headers are dropped, and `authorization` is kept only for the
+ `Signature` scheme.
+ - `inboxUrl`, the canonical IRI of the inbox, however the request spelled its
+ host or scheme.
+ - `verificationMechanism` and `verificationResult`, what Fedify reported of
+ verifying the request, as its OpenTelemetry manual documents: the
+ `activitypub.signature.verification.duration` result of each mechanism it
+ tried, the `activitypub.signature.key_fetch.duration` and
+ `activitypub.key.lookup` results of the keys it fetched for each, the
+ `*.verify` spans naming their keys, and the
+ `activitypub.activity.received` event. DrFed verifies nothing again. The
+ result is `unattempted` when Fedify answered before verifying, such as for
+ a body that is not JSON or an inbox whose owner does not exist, even if
+ the body carries a signature or proof. Proofs are found as JSON-LD,
+ however `proof` is spelled.
+ - A refusal is `key_fetch_error` rather than `invalid_signature` when the
+ last key fetch of the mechanism brought no usable key, whichever
+ mechanism it is: the signature was then not checked, or only against a
+ cached key that did not verify. `error` tells why as `keyFetchError:`
+ followed by the status the server of the key answered with, by `cached`
+ for the record of an earlier failure, or by the lookup result Fedify
+ counted, such as `network_error` or `invalid` for a document that holds no
+ key. When Fedify itself names the fetch of an HTTP signature's key as
+ failed, what follows is the status or the type of the error it reports.
+ - The result tells whether a signature or proof verified, not whether it
+ authenticated the activity. Object Integrity Proofs that verify without
+ their keys' controllers covering the activity's actor are `verified`, and
+ the refusal is `status` `rejected`, with `error` telling that Fedify did
+ not accept them, even when the HTTP signature Fedify went on to failed.
+ Fedify reports a Linked Data Signature that verifies without its key's
+ owner being the actor the same as one that does not verify, so that one
+ is `invalid_signature`.
+ - `verificationKey`, the public key version that mechanism used, even when
+ verification failed: the last key its cache entry held during a key fetch
+ of that one verification which brought a key, whether read or fetched,
+ even when fetching it again then failed. A key another mechanism of the
+ same request found under the same IRI is not it, nor is one read from the
+ cache that the verification could not use. `signedKeyIri` is the `keyId`
+ the HTTP signature declares. The two may name different keys.
+ - `status`, which follows the response Fedify gave: `received` when the inbox
+ listener ran, `acknowledged` when the request was answered 2xx without it
+ (a duplicate, for instance), `rejected` when a verified activity was
+ refused or its handling threw, and `unverified` otherwise. With an inbox
+ queue, a log is
+ `acknowledged` when the request is answered and becomes `received` once the
+ queue worker runs the listener; the queued message carries its log ID.
+
+`KeyVersion.firstSeen` and `lastSeen` are DrFed observation times, not remote
+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 logged `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 log 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
+logged. 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
+U+0000; anything else a remote server sent stays in `rawBody` and
+`requestHeaders`, and in `payload` when PostgreSQL can store it. `error` and
+`responseBody` keep what a remote server answered with U+FFFD for each U+0000.
+Logs are ordered by `created`, which for an inbound log is when the request
+arrived; `completed` is when DrFed answered it.
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index d1b6fcd..097f42c 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -81,6 +81,10 @@
"./origin": {
"types": "./dist/origin.d.mts",
"default": "./dist/origin.mjs"
+ },
+ "./activity-log": {
+ "types": "./dist/activity-log/entry.d.mts",
+ "default": "./dist/activity-log/entry.mjs"
}
},
"files": [
@@ -98,7 +102,8 @@
"src/instance.ts",
"src/schema.ts",
"src/object.ts",
- "src/origin.ts"
+ "src/origin.ts",
+ "src/activity-log/entry.ts"
],
"dts": {
"sourcemap": true,
@@ -125,6 +130,7 @@
"@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-log.test.ts b/packages/graphql/src/activity-log.test.ts
new file mode 100644
index 0000000..c1f8a2c
--- /dev/null
+++ b/packages/graphql/src/activity-log.test.ts
@@ -0,0 +1,804 @@
+// 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 .
+
+// Each request observes the preceding request's cache and database changes.
+
+// oxlint-disable no-await-in-loop max-statements id-length
+import assert from "node:assert/strict";
+import { it } from "node:test";
+
+import {
+ classifyInbound,
+ createInboundRecorder,
+ createKeyCache,
+ deliverActivity,
+ describeActivity,
+} from "@drfed/graphql/activity-log";
+import createFederation, {
+ type TrackedFederation,
+} from "@drfed/graphql/federation";
+import { schema } from "@drfed/models";
+import { recordInbound, recordOutbound } from "@drfed/models/activity-log";
+import { observeKeyVersion } from "@drfed/models/key";
+import {
+ type Context,
+ MemoryKvStore,
+ SendActivityError,
+ type SenderKeyPair,
+ generateCryptoKeyPair,
+ signRequest,
+} from "@fedify/fedify";
+import {
+ type Activity,
+ Create,
+ CryptographicKey,
+ type DocumentLoader,
+ Multikey,
+ Person,
+ type Recipient,
+} from "@fedify/vocab";
+import { eq, isNull } from "drizzle-orm";
+
+import { withInbox } from "./activity-log/remote.test.ts";
+import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts";
+import {
+ accountId,
+ globalId,
+ localActorId,
+ localInstanceId,
+ remoteActorId,
+ remoteInstanceId,
+ seedAuthenticatedLocalInstance,
+ seedLocalActor,
+ seedRemoteActor,
+} from "./seed.test.ts";
+
+const actorIri = new URL("https://remote.example/users/alice");
+const keyId = new URL(`${actorIri.href}#main-key`);
+const inbox = `https://test-instance.drfed.org/users/${localActorId}/inbox`;
+const fetchOptions = { contextData: undefined };
+const rootOrigin = new URL("https://drfed.org");
+const observed = {
+ verificationResult: "no_signature",
+ body: new TextEncoder().encode("{}"),
+ created: Temporal.Now.instant(),
+ completed: Temporal.Now.instant(),
+} as const;
+const payload = (id: string) => ({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: `https://remote.example/activities/${id}`,
+ type: "Create",
+ actor: actorIri.href,
+ object: {
+ id: `https://remote.example/notes/${id}`,
+ type: "Note",
+ content: "test",
+ },
+ bcc: ["https://private.example/recipient"],
+});
+const request = (body: unknown, url = inbox) =>
+ new Request(url, {
+ method: "POST",
+ headers: { "content-type": "application/activity+json" },
+ body: JSON.stringify(body),
+ });
+
+it("records signed, rotated, tampered and rejected inbox deliveries with the original JSON", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const kv = new MemoryKvStore();
+ const a = await generateCryptoKeyPair();
+ const b = await generateCryptoKeyPair();
+ let currentKey = a.publicKey;
+ // Fedify fetches the actor on every request to check that it owns the key,
+ // so only fetches of the key itself tell whether the cache served it.
+ let keyLoads = 0;
+ const documentLoader: DocumentLoader = async (url) => {
+ if (url === keyId.href) keyLoads += 1;
+ const key = new CryptographicKey({
+ id: keyId,
+ owner: actorIri,
+ publicKey: currentKey,
+ });
+ const document =
+ url === actorIri.href
+ ? await new Person({ id: actorIri, publicKey: key }).toJsonLd()
+ : await key.toJsonLd();
+ return { documentUrl: url, contextUrl: null, document };
+ };
+ const { contextLoader } = (
+ await createFederation(db, { kv })
+ ).createContext(new URL(inbox), undefined);
+ const federation = await createFederation(db, {
+ kv,
+ contextLoaderFactory: () => contextLoader,
+ documentLoaderFactory: () => documentLoader,
+ });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
+ const send = async (
+ body: unknown,
+ privateKey = a.privateKey,
+ url = inbox,
+ ) => {
+ const signed = await signRequest(request(body, url), privateKey, keyId);
+ return await recorder.fetch(signed, fetchOptions);
+ };
+ assert.equal((await send(payload("first"))).status, 202);
+ const [first] = await db.query.activityLogs.findMany();
+ assert.ok(first);
+ assert.equal(first.status, "received");
+ assert.equal(first.verificationMechanism, "http_signature");
+ assert.equal(first.verificationResult, "verified");
+ assert.equal(first.error, null);
+ assert.equal(first.requestUrl, inbox);
+ assert.deepEqual(
+ JSON.parse(new TextDecoder().decode(first.body!)),
+ payload("first"),
+ );
+ assert.equal(first.type, "Create");
+ assert.equal(first.objectType, "Note");
+ assert.equal(first.signedKeyIri, keyId.href);
+ assert.ok(first.verificationKeyId);
+ assert.equal(first.actorId, localActorId);
+ assert.deepEqual(first.payload, payload("first"));
+ const [firstVersion] = await db.query.keyVersions.findMany();
+ assert.ok(firstVersion);
+ // Digest failure happens before a key lookup. An existing cache entry is
+ // not evidence that this request used that key.
+ const badDigest = await signRequest(
+ request(payload("bad-digest")),
+ a.privateKey,
+ keyId,
+ );
+ badDigest.headers.set(
+ "digest",
+ "SHA-256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ );
+ assert.equal((await recorder.fetch(badDigest, fetchOptions)).status, 401);
+ const digestLog = await db.query.activityLogs.findFirst({
+ where: { activityIri: payload("bad-digest").id },
+ });
+ assert.equal(digestLog?.signedKeyIri, keyId.href);
+ assert.equal(digestLog?.verificationKeyId, null);
+ assert.equal(digestLog?.verificationResult, "invalid_signature");
+ const digestHeader = new Map(digestLog?.headers).get("digest");
+ assert.equal(
+ digestHeader,
+ "SHA-256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ );
+ assert.notEqual(
+ `SHA-256=${new Uint8Array(
+ await crypto.subtle.digest("SHA-256", new Uint8Array(digestLog!.body!)),
+ ).toBase64()}`,
+ digestHeader,
+ );
+ assert.equal(keyLoads, 1);
+ assert.equal((await send(payload("second"))).status, 202);
+ assert.equal(keyLoads, 1);
+ assert.equal(await db.$count(schema.keyVersions), 1);
+ const [seenAgain] = await db.query.keyVersions.findMany();
+ assert.ok(seenAgain);
+ assert.ok(
+ Temporal.Instant.compare(seenAgain.lastSeen, firstVersion.lastSeen) > 0,
+ );
+ currentKey = b.publicKey;
+ await kv.delete(["_fedify", "publicKey", "2", keyId.href]);
+ assert.equal((await send(payload("rotated"), b.privateKey)).status, 202);
+ assert.equal(await db.$count(schema.keyVersions), 2);
+ assert.equal(
+ (await db.query.activityLogs.findFirst({ where: { id: first.id } }))
+ ?.verificationKeyId,
+ firstVersion.id,
+ );
+ // Sign with A while the advertised key is B: cryptographic verification fails.
+ assert.equal((await send(payload("tampered"))).status, 401);
+ const bad = await db.query.activityLogs.findFirst({
+ where: { activityIri: payload("tampered").id },
+ });
+ assert.equal(bad?.status, "unverified");
+ assert.equal(bad?.verificationResult, "invalid_signature");
+ assert.ok(bad?.verificationKeyId);
+ const mismatch = {
+ ...payload("mismatch"),
+ actor: "https://other.example/actor",
+ };
+ assert.equal((await send(mismatch, b.privateKey)).status, 401);
+ const rejected = await db.query.activityLogs.findFirst({
+ where: { activityIri: mismatch.id },
+ });
+ assert.equal(rejected?.status, "rejected");
+ assert.equal(rejected?.verificationResult, "verified");
+ assert.equal(rejected?.error, rejected?.responseBody);
+ assert.match(rejected?.error ?? "", /do not match/u);
+ assert.equal(
+ (
+ await send(
+ payload("shared"),
+ b.privateKey,
+ "https://test-instance.drfed.org/inbox",
+ )
+ ).status,
+ 202,
+ );
+ assert.equal(
+ (
+ await db.query.activityLogs.findFirst({
+ where: { activityIri: payload("shared").id },
+ })
+ )?.actorId,
+ null,
+ );
+ await kv.delete(["_fedify", "publicKey", "2", keyId.href]);
+ assert.equal(await db.$count(schema.keyVersions), 2);
+ });
+});
+
+it("records missing signatures, failed key fetches and non-JSON bodies, and skips non-inbox requests", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const kv = new MemoryKvStore();
+ const { contextLoader } = (
+ await createFederation(db, { kv })
+ ).createContext(new URL(inbox), undefined);
+ const federation = await createFederation(db, {
+ kv,
+ contextLoaderFactory: () => contextLoader,
+ documentLoaderFactory: () => () =>
+ Promise.reject(new TypeError("offline")),
+ });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
+ assert.equal(
+ (await recorder.fetch(request(payload("unsigned")), fetchOptions)).status,
+ 401,
+ );
+ const [unsigned] = await db.query.activityLogs.findMany();
+ assert.ok(unsigned);
+ assert.equal(unsigned.status, "unverified");
+ assert.equal(unsigned.signedKeyIri, null);
+ assert.equal(unsigned.verificationKeyId, null);
+ assert.equal(unsigned.verificationMechanism, "http_signature");
+ assert.equal(unsigned.verificationResult, "no_signature");
+ const pair = await generateCryptoKeyPair();
+ assert.equal(
+ (
+ await recorder.fetch(
+ await signRequest(
+ request(payload("unavailable")),
+ pair.privateKey,
+ keyId,
+ ),
+ fetchOptions,
+ )
+ ).status,
+ 401,
+ );
+ const failed = await db.query.activityLogs.findFirst({
+ where: { activityIri: payload("unavailable").id },
+ });
+ assert.equal(failed?.signedKeyIri, keyId.href);
+ assert.equal(failed?.verificationKeyId, null);
+ assert.equal(failed?.verificationResult, "key_fetch_error");
+ assert.match(failed?.error ?? "", /keyFetchError/u);
+ const invalid = await recorder.fetch(
+ new Request(inbox, { method: "POST", body: "not JSON" }),
+ fetchOptions,
+ );
+ assert.equal(invalid.status, 400);
+ const unparsed = await db.query.activityLogs.findFirst({
+ where: { statusCode: 400 },
+ });
+ assert.equal(
+ new TextDecoder().decode(unparsed?.body ?? undefined),
+ "not JSON",
+ );
+ assert.equal(unparsed?.type, null);
+ assert.deepEqual(unparsed?.types, []);
+ // Fedify refused the body before verifying anything.
+ assert.equal(unparsed?.verificationMechanism, null);
+ assert.equal(unparsed?.verificationResult, "unattempted");
+ assert.equal(
+ await db.$count(schema.activityLogs, isNull(schema.activityLogs.payload)),
+ 1,
+ );
+ await recorder.fetch(new Request(inbox), fetchOptions);
+ await recorder.fetch(
+ request({}, "https://test-instance.drfed.org/not-an-inbox"),
+ fetchOptions,
+ );
+ assert.equal(await db.$count(schema.activityLogs), 3);
+ await recorder.fetch(request(null), fetchOptions);
+ assert.equal(await db.$count(schema.activityLogs), 4);
+ assert.equal(
+ await db.$count(schema.activityLogs, isNull(schema.activityLogs.payload)),
+ 1,
+ );
+ await db.execute(`DROP TABLE activity_logs CASCADE`);
+ assert.equal(
+ (await recorder.fetch(request(payload("log-failure")), fetchOptions))
+ .status,
+ 401,
+ );
+ });
+});
+
+it("uses the Fedify KV serialization for RSA, Multikey and negative entries", async () => {
+ const kv = new MemoryKvStore();
+ const cache = createKeyCache(kv);
+ const rsa = await generateCryptoKeyPair();
+ const key = new CryptographicKey({
+ id: keyId,
+ owner: actorIri,
+ publicKey: rsa.publicKey,
+ });
+ await cache.set(keyId, key);
+ assert.deepEqual(
+ await kv.get(["_fedify", "publicKey", "2", keyId.href]),
+ await key.toJsonLd(),
+ );
+ assert.ok((await cache.get(keyId)) instanceof CryptographicKey);
+ const ed = await generateCryptoKeyPair("Ed25519");
+ const multi = new Multikey({
+ id: keyId,
+ controller: actorIri,
+ publicKey: ed.publicKey,
+ });
+ await kv.set(
+ ["_fedify", "publicKey", "2", keyId.href],
+ await multi.toJsonLd(),
+ );
+ assert.ok((await cache.get(keyId)) instanceof Multikey);
+ await cache.set(keyId, null);
+ assert.equal(await cache.get(keyId), null);
+ await cache.setFetchError(keyId, { error: new TypeError("offline") });
+ assert.equal(
+ ((await cache.getFetchError(keyId)) as { error: Error }).error.name,
+ "TypeError",
+ );
+ await kv.set(["_fedify", "publicKey", "2", keyId.href], "not a key");
+ assert.equal(await cache.get(keyId), undefined);
+});
+
+it("classifies accepted proofs independently of HTTP signature failure and describes malformed JSON-LD", async () => {
+ assert.deepEqual(await describeActivity({}), {
+ type: null,
+ types: [],
+ activityIri: null,
+ remoteActorIri: null,
+ objectType: null,
+ objectIri: null,
+ });
+ assert.equal(
+ (await describeActivity({ type: "Extension", id: "urn:test" })).type,
+ "Extension",
+ );
+ for (const [statusCode, handled, verificationResult, status] of [
+ [202, true, "no_signature", "received"],
+ [202, true, "invalid_signature", "received"],
+ [202, false, "verified", "acknowledged"],
+ [401, false, "verified", "rejected"],
+ [401, false, "unobserved", "unverified"],
+ [400, false, "no_signature", "unverified"],
+ // Handling threw instead of answering.
+ [null, true, "verified", "rejected"],
+ [null, false, "unattempted", "unverified"],
+ ] as const) {
+ assert.equal(
+ classifyInbound({ statusCode, handled, verificationResult }),
+ status,
+ );
+ }
+ assert.deepEqual(
+ await describeActivity({
+ "@context": 123,
+ type: "Extension",
+ id: "urn:test",
+ actor: ["unknown"],
+ object: "urn:object",
+ }),
+ {
+ type: "Extension",
+ types: ["Extension"],
+ activityIri: "urn:test",
+ remoteActorIri: null,
+ objectType: null,
+ objectIri: null,
+ },
+ );
+});
+
+it("paginates tied timestamps, filters logs and reads verification keys as an instance member", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const version = await observeKeyVersion(db, {
+ iri: keyId.href,
+ publicKey: { kty: "RSA", e: "AQAB", n: "test" },
+ });
+ const created = Temporal.Instant.from("2026-09-01T00:00:00.123456Z");
+ const rows = [];
+ for (const status of ["received", "unverified", "rejected"] as const) {
+ rows.push(
+ await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ status,
+ type: status === "received" ? "Create" : "Follow",
+ verificationKeyId: version.id,
+ payload: { bcc: ["private"] },
+ created,
+ }),
+ );
+ }
+ const outbound = await recordOutbound(db, {
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ activityIri: "https://local.example/activity",
+ type: "Create",
+ payload: {},
+ created,
+ });
+ const query = `query($id: ID!, $after: String, $filter: ActivityLogFilter) {
+ node(id: $id) { ... on Instance { activityLogs(first: 2, after: $after, filter: $filter) {
+ edges { cursor node { uuid direction status type payload verificationKey { fingerprint publicKey key { iri versions { edges { node { uuid } } } } } } }
+ pageInfo { hasNextPage endCursor }
+ } } }
+ }`;
+ const page = async (after?: string, filter?: Record) => {
+ const result = await (
+ await post(
+ {
+ query,
+ variables: {
+ id: globalId("Instance", localInstanceId),
+ after,
+ filter,
+ },
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ return result.data.node.activityLogs;
+ };
+ const first = await page();
+ const second = await page(first.pageInfo.endCursor);
+ assert.equal(first.pageInfo.hasNextPage, true);
+ assert.equal(second.pageInfo.hasNextPage, false);
+ assert.deepEqual(
+ [...first.edges, ...second.edges].map(
+ (edge: { node: { uuid: string } }) => edge.node.uuid,
+ ),
+ [outbound, ...rows.toReversed()].map((row) => row.id),
+ );
+ assert.equal(first.edges[1].node.verificationKey.key.iri, keyId.href);
+ assert.equal(
+ (await page(undefined, { direction: "outbound" })).edges.length,
+ 1,
+ );
+ assert.equal(
+ (await page(undefined, { status: "received" })).edges.length,
+ 1,
+ );
+ assert.equal((await page(undefined, { type: "Create" })).edges.length, 2);
+ const actorResult = await (
+ await post(
+ {
+ query: `{ node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 10) { edges { node { uuid } } } } } }`,
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(actorResult.errors, undefined);
+ assert.equal(actorResult.data.node.activityLogs.edges.length, 4);
+ });
+});
+
+it("denies anonymous/nonmember access including node typename and remote connections", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ await seedRemoteActor(db);
+ const log = await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ status: "received",
+ payload: {},
+ });
+ const logId = Buffer.from(`ActivityLog:${log.id}`).toString("base64");
+ const queries = [
+ `{ node(id: "${logId}") { __typename } }`,
+ `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs { pageInfo { hasNextPage } } } } }`,
+ `{ node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs { pageInfo { hasNextPage } } } } }`,
+ ];
+ for (const query of queries) {
+ assert.ok((await (await post({ query })).json()).errors?.length);
+ }
+ await db
+ .delete(schema.instanceMembers)
+ .where(eq(schema.instanceMembers.accountId, accountId));
+ for (const query of queries) {
+ assert.ok((await (await post({ query }, auth)).json()).errors?.length);
+ }
+ await db
+ .update(schema.accounts)
+ .set({ admin: true })
+ .where(eq(schema.accounts.id, accountId));
+ assert.equal(
+ (await (await post({ query: queries[0]! }, auth)).json()).errors,
+ undefined,
+ );
+ for (const [type, id] of [
+ ["Instance", remoteInstanceId],
+ ["Actor", remoteActorId],
+ ] as const) {
+ assert.ok(
+ (
+ await (
+ await post(
+ {
+ query: `{ node(id: "${globalId(type, id)}") { ... on ${type} { activityLogs { pageInfo { hasNextPage } } } } }`,
+ },
+ auth,
+ )
+ ).json()
+ ).errors?.length,
+ );
+ }
+ });
+});
+
+/**
+ * A key of the test's own to deliver with, since local key pairs arrive with
+ * #87.
+ * @returns The key pair.
+ */
+async function testKey(): Promise {
+ const { privateKey } = await generateCryptoKeyPair("Ed25519");
+ return { keyId: new URL(`${actorIri.href}#key`), privateKey };
+}
+
+it("settles synchronous delivery and retains HTTP failure diagnostics", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const federation = await createFederation(db, {
+ kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
+ });
+ const ctx = federation.createContext(new URL(inbox), undefined);
+ const key = await testKey();
+ const sender = { identifier: localActorId };
+ const fakeContext = (
+ sendActivity: Context["sendActivity"],
+ ): Context =>
+ new Proxy(ctx, {
+ get(target, property) {
+ return property === "sendActivity"
+ ? sendActivity
+ : Reflect.get(target, property);
+ },
+ });
+ // Fedify delivers for real: only its report of the delivery makes it sent.
+ await withInbox([[202, ""]], async (remote) => {
+ const recipient = { id: actorIri, inboxId: remote };
+ const activity = new Create({
+ id: new URL("https://test-instance.drfed.org/activity/1"),
+ actor: ctx.getActorUri(localActorId),
+ });
+ await deliverActivity(
+ db,
+ fakeContext((_sender, recipients, sent) =>
+ ctx.sendActivity(key, recipients as Recipient[], sent),
+ ),
+ sender,
+ [recipient, recipient],
+ activity,
+ );
+ });
+ assert.equal(await db.$count(schema.activityLogs), 1);
+ const sent = await db.query.activityLogs.findFirst({
+ with: { attempts: true },
+ });
+ assert.equal(sent?.status, "sent");
+ assert.equal(sent.statusCode, 202);
+ assert.ok(sent.completed != null);
+ assert.deepEqual(
+ sent.attempts.map((attempt) => attempt.succeeded),
+ [true],
+ );
+ // Fedify reports a synchronous failure only by throwing it.
+ const recipient = {
+ id: actorIri,
+ inboxId: new URL("https://remote.example/inbox"),
+ };
+ const failure = new SendActivityError(
+ recipient.inboxId,
+ 410,
+ "gone",
+ "Gone forever",
+ );
+ const failActivity = new Create({
+ id: new URL("https://test-instance.drfed.org/activity/2"),
+ actor: ctx.getActorUri(localActorId),
+ });
+ await assert.rejects(
+ deliverActivity(
+ db,
+ fakeContext(() => Promise.reject(failure)),
+ sender,
+ recipient,
+ failActivity,
+ ),
+ SendActivityError,
+ );
+ const failed = await db.query.activityLogs.findFirst({
+ where: { activityIri: failActivity.id!.href },
+ with: { attempts: true },
+ });
+ assert.equal(failed?.status, "failed");
+ assert.equal(failed?.statusCode, 410);
+ assert.equal(failed?.error, "gone");
+ assert.equal(failed?.responseBody, "Gone forever");
+ assert.deepEqual(
+ failed?.attempts.map((attempt) => [
+ attempt.succeeded,
+ attempt.statusCode,
+ attempt.error,
+ attempt.responseBody,
+ ]),
+ [[false, 410, "gone", "Gone forever"]],
+ );
+ });
+});
+
+it("settles successful inboxes independently from thrown delivery failures", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const federation = await createFederation(db, {
+ kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
+ });
+ const context = federation.createContext(new URL(inbox), undefined);
+ const key = await testKey();
+ const bad = {
+ id: actorIri,
+ inboxId: new URL("https://remote.example/bad"),
+ };
+ const activity = new Create({
+ id: new URL("https://test-instance.drfed.org/activity/mixed"),
+ actor: context.getActorUri(localActorId),
+ });
+ const ctx = new Proxy(context, {
+ get(target, property) {
+ if (property !== "sendActivity") return Reflect.get(target, property);
+ return (_sender: unknown, recipients: Recipient[], sent: Activity) =>
+ recipients[0]?.inboxId?.href === bad.inboxId.href
+ ? Promise.reject(
+ new SendActivityError(
+ bad.inboxId,
+ 503,
+ "unavailable",
+ "Try later",
+ ),
+ )
+ : target.sendActivity(key, recipients, sent);
+ },
+ });
+ await withInbox([[202, ""]], async (remote) => {
+ const good = { id: actorIri, inboxId: remote };
+ await assert.rejects(
+ deliverActivity(
+ db,
+ ctx,
+ { identifier: localActorId },
+ [good, bad],
+ activity,
+ ),
+ SendActivityError,
+ );
+ });
+ const rows = await db.query.activityLogs.findMany({
+ orderBy: { inboxUrl: "asc" },
+ });
+ assert.equal(rows[0]?.status, "sent");
+ assert.equal(rows[0]?.statusCode, 202);
+ assert.equal(rows[1]?.status, "failed");
+ assert.equal(rows[1]?.statusCode, 503);
+ assert.equal(rows[1]?.error, "unavailable");
+ assert.equal(rows[1]?.responseBody, "Try later");
+ });
+});
+
+it("returns a literal JSON null payload without nulling its connection", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ inboxUrl: inbox,
+ status: "unverified",
+ payload: null,
+ });
+ const result = await (
+ await post(
+ {
+ query: `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node { status payload } } } } } }`,
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined);
+ assert.deepEqual(result.data.node.activityLogs.edges[0].node, {
+ status: "unverified",
+ payload: null,
+ });
+ });
+});
+
+it("skips verification observations for unclaimed hosts and failed instance lookups", async () => {
+ await withTemporaryDatabase(async (db) => {
+ const kv = new MemoryKvStore();
+ const base = await createFederation(db, { kv });
+ const pair = await generateCryptoKeyPair();
+ let reads = 0;
+ const passThrough = new Response("No local instance", { status: 404 });
+ const federation = {
+ createContext: (input: Request, data: unknown) => {
+ const ctx = base.createContext(input, data);
+ return new Proxy(ctx, {
+ get(target, property) {
+ if (property === "documentLoader") {
+ return () => {
+ reads += 1;
+ return Promise.reject(new Error("Unexpected key lookup"));
+ };
+ }
+ const value = Reflect.get(target, property);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+ },
+ fetch: () => Promise.resolve(passThrough),
+ } as unknown as TrackedFederation;
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
+ const signed = await signRequest(
+ request(payload("unknown-host")),
+ pair.privateKey,
+ keyId,
+ );
+ assert.equal(
+ await recorder.fetch(signed.clone(), fetchOptions),
+ passThrough,
+ );
+ assert.equal(reads, 0);
+ assert.equal(await db.$count(schema.keys), 0);
+ assert.equal(await db.$count(schema.keyVersions), 0);
+ assert.equal(await db.$count(schema.activityLogs), 0);
+ await db.execute(
+ "ALTER TABLE instances RENAME TO temporarily_unavailable_instances",
+ );
+ assert.equal(
+ await recorder.fetch(signed.clone(), fetchOptions),
+ passThrough,
+ );
+ assert.equal(reads, 0);
+ assert.equal(await db.$count(schema.keyVersions), 0);
+ });
+});
diff --git a/packages/graphql/src/activity-log/addressing.ts b/packages/graphql/src/activity-log/addressing.ts
new file mode 100644
index 0000000..be22b20
--- /dev/null
+++ b/packages/graphql/src/activity-log/addressing.ts
@@ -0,0 +1,78 @@
+// 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 { AddressedActor } from "@drfed/models/activity-log";
+import type { Uuid } from "@drfed/models/uuid";
+import { type Activity, PUBLIC_COLLECTION } from "@fedify/vocab";
+
+/**
+ * The IRIs an activity addresses, without Public.
+ * @returns The distinct addressed IRIs.
+ */
+export function addressedIris(activity: Activity | null): string[] {
+ if (activity == null) return [];
+ const iris = [
+ ...activity.toIds,
+ ...activity.btoIds,
+ ...activity.ccIds,
+ ...activity.bccIds,
+ ...activity.audienceIds,
+ ].map((iri) => iri.href);
+ return [...new Set(iris)].filter((iri) => iri !== PUBLIC_COLLECTION.href);
+}
+
+/**
+ * Resolve addressed IRIs to local actors, directly or through stored
+ * collection membership.
+ * @returns One entry per way an actor is reached.
+ */
+export async function findAddressedActors(
+ db: Database,
+ instanceId: Uuid,
+ iris: readonly string[],
+): Promise {
+ if (iris.length === 0) return [];
+ const local = {
+ instanceId,
+ localId: { isNotNull: true },
+ deleted: { isNull: true },
+ } as const;
+ const direct = await db.query.actors.findMany({
+ columns: { id: true },
+ where: { ...local, resource: { iri: { in: [...iris] } } },
+ });
+ const collections = await db.query.collections.findMany({
+ columns: { id: true },
+ where: { resource: { iri: { in: [...iris] } } },
+ with: {
+ resource: { columns: { iri: true } },
+ items: {
+ columns: { itemId: true },
+ where: { item: { actor: local } },
+ },
+ },
+ });
+ return [
+ ...direct.map(({ id }) => ({ actorId: id })),
+ ...collections.flatMap(({ resource, items }) =>
+ items.map(({ itemId }) => ({
+ actorId: itemId,
+ viaCollectionIri: resource.iri,
+ })),
+ ),
+ ];
+}
diff --git a/packages/graphql/src/activity-log/describe.ts b/packages/graphql/src/activity-log/describe.ts
new file mode 100644
index 0000000..5f7ff1c
--- /dev/null
+++ b/packages/graphql/src/activity-log/describe.ts
@@ -0,0 +1,133 @@
+// 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 { Object as APObject, Activity, getTypeId } from "@fedify/vocab";
+
+type Options = Parameters[1];
+
+function document(value: unknown): Record {
+ return value != null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+/**
+ * PostgreSQL text holds no U+0000; the original stays in the raw request.
+ * @returns Whether the value is a string text can hold.
+ */
+const text = (value: unknown): value is string =>
+ typeof value === "string" && !value.includes("\0");
+const string = (value: unknown): string | null => (text(value) ? value : null);
+const strings = (value: unknown): string[] =>
+ (Array.isArray(value) ? value : [value]).filter(text);
+const first = (value: unknown): unknown =>
+ Array.isArray(value) ? value[0] : value;
+
+/**
+ * Keep an untrusted IRI only when it is an absolute URL text can hold; the
+ * original stays in the raw request.
+ * @returns The IRI, or null when it is not a valid URL or holds U+0000.
+ */
+export const iri = (value: unknown): string | null =>
+ text(value) && URL.canParse(value) ? value : null;
+
+/**
+ * Parse JSON-LD as an activity without dereferencing.
+ * @returns The activity, or null when the document is not one.
+ */
+export async function parseActivity(
+ payload: unknown,
+ options: Options = {},
+): Promise {
+ try {
+ // The Activity constructor accepts even {} as a base Activity. Parse the
+ // actual vocabulary type first so malformed JSON does not invent a type.
+ const activity = await APObject.fromJsonLd(payload, options);
+ return activity instanceof Activity ? activity : null;
+ } catch {
+ return null;
+ }
+}
+
+function resolvedType(types: readonly string[], activity: Activity) {
+ const { href, hash } = getTypeId(activity);
+ return (
+ types.find((type) => type === href || `#${type}` === hash) ??
+ types[0] ??
+ null
+ );
+}
+
+/**
+ * Describe an already parsed activity.
+ * @returns Best-effort activity metadata.
+ */
+export async function describeParsed(
+ payload: unknown,
+ activity: Activity | null,
+ options: Options = {},
+) {
+ const raw = document(payload);
+ const types = strings(raw.type ?? raw["@type"]);
+ if (activity == null) {
+ return {
+ type: string(raw.type),
+ types,
+ activityIri: iri(raw.id),
+ remoteActorIri: iri(raw.actor),
+ objectType: null,
+ objectIri: null,
+ };
+ }
+ const value = document(
+ await activity.toJsonLd({ ...options, format: "compact" }),
+ );
+ const object = document(first(value.object));
+ return {
+ type: string(value.type) ?? resolvedType(types, activity),
+ types,
+ activityIri: iri(value.id),
+ remoteActorIri: activity.actorIds[0]?.href ?? null,
+ objectType: string(first(object.type)),
+ objectIri: iri(first(value.object)) ?? iri(object.id),
+ };
+}
+
+/**
+ * Describe JSON-LD without dereferencing its actor or object.
+ * @returns Best-effort activity metadata.
+ */
+export async function describeActivity(
+ payload: unknown,
+ options: Options = {},
+) {
+ return await describeParsed(
+ payload,
+ await parseActivity(payload, options),
+ options,
+ );
+}
+
+/**
+ * Best-effort host extraction from untrusted activity metadata.
+ * @returns The remote host, or null when unavailable.
+ */
+export function remoteHost(
+ actorIri: string | null,
+ keyIri: string | null,
+): string | null {
+ const url = iri(actorIri ?? keyIri);
+ return url == null ? null : new URL(url).host || null;
+}
diff --git a/packages/graphql/src/activity-log/entry.ts b/packages/graphql/src/activity-log/entry.ts
new file mode 100644
index 0000000..54a354c
--- /dev/null
+++ b/packages/graphql/src/activity-log/entry.ts
@@ -0,0 +1,407 @@
+// 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 {
+ activityLogDirectionEnum,
+ activityLogStatusEnum,
+ activityLogVerificationMechanismEnum,
+ activityLogVerificationResultEnum,
+} from "@drfed/models/schema";
+import type { Uuid } from "@drfed/models/uuid";
+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 ActivityLogDirection = builder.enumType("ActivityLogDirection", {
+ description: "Whether DrFed received the delivery or made it.",
+ values: activityLogDirectionEnum.enumValues,
+});
+const ActivityLogStatus = builder.enumType("ActivityLogStatus", {
+ description:
+ "What became of a delivery. Inbound values follow the response DrFed " +
+ "gave, a request whose handling threw counting as refused; outbound " +
+ "values follow the delivery attempt.",
+ values: {
+ received: {
+ description: "Inbound: answered 2xx and the inbox listener ran.",
+ },
+ acknowledged: {
+ description:
+ "Inbound: answered 2xx without running the inbox listener, e.g. a " +
+ "duplicate of an already processed activity or an unsupported type.",
+ },
+ unverified: {
+ description: "Inbound: refused, and no signature or proof was verified.",
+ },
+ rejected: {
+ description:
+ "Inbound: refused although a signature or proof was verified.",
+ },
+ queued: {
+ description: "Outbound: started, and no attempt has ended yet.",
+ },
+ sent: { description: "Outbound: the remote inbox accepted the delivery." },
+ failed: {
+ description:
+ "Outbound: the latest attempt failed. With a message queue, it may " +
+ "still be retried.",
+ },
+ permanently_failed: {
+ description:
+ "Outbound: given up without further retries, because the remote " +
+ "inbox answered with a permanent failure status, or because the " +
+ "circuit breaker for its host held the delivery too long.",
+ },
+ abandoned: {
+ description:
+ "Outbound: the retry policy ran out after the latest attempt failed.",
+ },
+ } satisfies Record<(typeof activityLogStatusEnum.enumValues)[number], object>,
+});
+const VerificationMechanism = builder.enumType(
+ "ActivityLogVerificationMechanism",
+ {
+ description:
+ "How an inbound activity was authenticated. Not to be confused with " +
+ "FEP-8b32's `verificationMethod`, which is a key.",
+ values: {
+ http_signature: { description: "The HTTP request signature." },
+ ld_signature: { description: "Linked Data Signatures (`signature`)." },
+ object_integrity_proof: {
+ description: "FEP-8b32 Object Integrity Proofs (`proof`).",
+ },
+ } satisfies Record<
+ (typeof activityLogVerificationMechanismEnum.enumValues)[number],
+ object
+ >,
+ },
+);
+const VerificationResult = builder.enumType("ActivityLogVerificationResult", {
+ description:
+ "What Fedify reported of verifying an inbound activity, as it " +
+ "verified it. Whether the activity was accepted is " +
+ "`ActivityLog.status`.",
+ values: {
+ verified: { description: "A signature or proof was verified." },
+ invalid_signature: {
+ description: "A signature or proof was present and did not verify.",
+ },
+ key_fetch_error: {
+ description:
+ "The key a signature or proof names could not be fetched, or what " +
+ "was fetched held no usable key.",
+ },
+ no_signature: { description: "Nothing to verify was found." },
+ unattempted: {
+ description:
+ "Fedify answered before verifying anything, e.g. a body that is " +
+ "not JSON or an unknown inbox.",
+ },
+ unobserved: {
+ description:
+ "Recording the observation failed; the mechanism is unknown.",
+ },
+ } satisfies Record<
+ (typeof activityLogVerificationResultEnum.enumValues)[number],
+ object
+ >,
+});
+const ActivityLogFilter = builder.inputType("ActivityLogFilter", {
+ fields: (t) => ({
+ direction: t.field({ type: ActivityLogDirection }),
+ status: t.field({ type: ActivityLogStatus }),
+ type: t.string(),
+ }),
+});
+function decodeUtf8(body: Uint8Array | null): string | null {
+ if (body == null) return null;
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(body);
+ } catch {
+ return null;
+ }
+}
+const access = (localId: Uuid | null) =>
+ localId == null
+ ? false
+ : { $any: { admin: true as const, localInstanceMember: localId } };
+
+builder.drizzleObject("activityLogAttempts", {
+ name: "ActivityLogAttempt",
+ description: "One ended attempt of an outbound delivery.",
+ fields: (t) => ({
+ uuid: t.expose("id", { type: "UUID" }),
+ succeeded: t.exposeBoolean("succeeded"),
+ statusCode: t.exposeInt("statusCode", {
+ nullable: true,
+ description:
+ "The status the remote inbox answered the attempt with. Null " +
+ "when no response came, as for a network error.",
+ }),
+ responseBody: t.exposeString("responseBody", {
+ nullable: true,
+ description:
+ "What the remote inbox answered a failed attempt, with U+FFFD for " +
+ "each U+0000, which PostgreSQL cannot store.",
+ }),
+ error: t.exposeString("error", {
+ nullable: true,
+ description:
+ "Why the attempt failed, including the causes of a network error. " +
+ "Null for a success.",
+ }),
+ created: t.expose("created", {
+ type: "DateTime",
+ description: "When the attempt ended.",
+ }),
+ }),
+});
+
+const ActivityLogRef = builder.drizzleNode("activityLogs", {
+ name: "ActivityLog",
+ select: { with: { instance: { columns: { localId: true } } } },
+ authScopes: (log) => access(log.instance.localId),
+ runScopesOnType: true,
+ id: { column: (log) => log.id },
+ fields: (t) => ({
+ uuid: t.expose("id", { type: "UUID" }),
+ instance: t.relation("instance"),
+ actor: t.relation("actor", {
+ nullable: true,
+ description:
+ "The owner of the inbox the request arrived at, or the sending " +
+ "actor. Null for the shared inbox, and for a deleted actor.",
+ }),
+ direction: t.expose("direction", { type: ActivityLogDirection }),
+ status: t.expose("status", {
+ type: ActivityLogStatus,
+ description:
+ "The outcome of the delivery. Inbound, it follows the response " +
+ "DrFed gave, not `verificationResult`.",
+ }),
+ verificationMechanism: t.expose("verificationMechanism", {
+ type: VerificationMechanism,
+ nullable: true,
+ description:
+ "Inbound: the mechanism that verified the activity, or the last " +
+ "one Fedify tried. Null when Fedify tried none, when recording " +
+ "failed, and for outbound logs.",
+ }),
+ verificationResult: t.expose("verificationResult", {
+ type: VerificationResult,
+ nullable: true,
+ description: "Inbound: what verifying found. Null for outbound logs.",
+ }),
+ type: t.exposeString("type", {
+ nullable: true,
+ description: "The one type the activity resolved to; see `types`.",
+ }),
+ types: t.exposeStringList("types", {
+ description: "Every type the activity declares.",
+ }),
+ activityIri: t.expose("activityIri", { type: "URL", nullable: true }),
+ objectType: t.exposeString("objectType", { nullable: true }),
+ objectIri: t.expose("objectIri", { type: "URL", nullable: true }),
+ signedKeyIri: t.expose("signedKeyIri", {
+ type: "URL",
+ nullable: true,
+ description:
+ "The `keyId` the HTTP signature declares. It may differ from the " +
+ "IRI of `verificationKey` when another mechanism verified the " +
+ "activity.",
+ }),
+ verificationKey: t.relation("verificationKey", {
+ nullable: true,
+ description:
+ "The public key version `verificationMechanism` actually used, even " +
+ "when it failed. Its presence does not imply success; consult " +
+ "`verificationResult`.",
+ }),
+ remoteActorIri: t.expose("remoteActorIri", { type: "URL", nullable: true }),
+ remoteHost: t.exposeString("remoteHost", { nullable: true }),
+ inboxUrl: t.expose("inboxUrl", {
+ type: "URL",
+ description:
+ "The canonical IRI of the inbox, however the request spelled it; " +
+ "see `requestUrl`.",
+ }),
+ requestUrl: t.expose("requestUrl", {
+ type: "URL",
+ nullable: true,
+ description: "Inbound: the URL the request actually arrived at.",
+ }),
+ requestHeaders: t.expose("headers", {
+ type: "JSON",
+ nullable: true,
+ description:
+ "Inbound: the request headers as `[name, value]` pairs with " +
+ "lowercase names; their original order and case are not kept. " +
+ "`cookie` is left out, and `authorization` is whole only for the " +
+ "`Signature` scheme.",
+ }),
+ rawBody: t.string({
+ nullable: true,
+ description:
+ "Inbound: the request body as received, when it is valid UTF-8; " +
+ "otherwise read `rawBodyBase64`.",
+ select: { columns: { body: true } },
+ resolve: (log) => decodeUtf8(log.body),
+ }),
+ rawBodyBase64: t.string({
+ nullable: true,
+ description: "Inbound: the octets of the request body, in Base64.",
+ select: { columns: { body: true } },
+ resolve: (log) => log.body?.toString("base64") ?? null,
+ }),
+ statusCode: t.exposeInt("statusCode", {
+ nullable: true,
+ description:
+ "Inbound: what DrFed answered; null when handling the request " +
+ "threw. Outbound: what the remote inbox answered the latest " +
+ "attempt; null when no response came.",
+ }),
+ responseBody: t.exposeString("responseBody", {
+ nullable: true,
+ description:
+ "Inbound: what DrFed answered; null when handling the request " +
+ "threw. Outbound: what the remote inbox answered the latest failed " +
+ "attempt, with U+FFFD for each U+0000, which PostgreSQL cannot store.",
+ }),
+ error: t.exposeString("error", {
+ nullable: true,
+ description:
+ "Why a refused or failed delivery ended that way, which for an " +
+ "inbound request whose handling threw is the exception. Null for " +
+ "accepted ones.",
+ }),
+ attempts: t.relation("attempts", {
+ query: { orderBy: { created: "asc", id: "asc" } },
+ description:
+ "Outbound: each attempt that ended, oldest first, including the " +
+ "retries of a queued delivery. Empty for inbound logs.",
+ }),
+ payload: t.expose("payload", {
+ type: "JSON",
+ nullable: true,
+ description:
+ "Inbound: `rawBody` parsed as JSON, which loses key order, " +
+ "whitespace and duplicate keys; null when it does not parse, when " +
+ "it holds U+0000 or an unpaired surrogate, which PostgreSQL cannot " +
+ "store, and for a literal JSON null. Outbound: the compact JSON-LD " +
+ "before signing, without `bto` and `bcc`, so it lacks the `proof` " +
+ "and `signature` the remote server received. May contain " +
+ "unverified remote input and private recipients.",
+ }),
+ recipientIris: t.expose("recipientIris", {
+ type: ["URL"],
+ description:
+ "Outbound: every recipient sharing the inbox, including those " +
+ "named by `bto` and `bcc`.",
+ }),
+ created: t.expose("created", {
+ type: "DateTime",
+ description:
+ "Inbound: when the request arrived, before it was verified and " +
+ "handled. Outbound: when DrFed started the delivery. Logs are " +
+ "ordered by it.",
+ }),
+ completed: t.expose("completed", {
+ type: "DateTime",
+ nullable: true,
+ description:
+ "Inbound: when DrFed answered the request. Outbound: when " +
+ "`status` last changed. Null while an outbound delivery is " +
+ "`queued`.",
+ }),
+ }),
+});
+export const ActivityLog: DrFedObjectRef = ActivityLogRef;
+
+const logsConnection = drizzleConnectionHelpers(builder, "activityLogs", {
+ query: ({
+ filter,
+ }: {
+ filter?: typeof ActivityLogFilter.$inferInput | null;
+ }) => ({
+ where: {
+ ...(filter?.direction == null ? {} : { direction: filter.direction }),
+ ...(filter?.status == null ? {} : { status: filter.status }),
+ ...(filter?.type == null ? {} : { type: filter.type }),
+ },
+ orderBy: { created: "desc", id: "desc" },
+ }),
+});
+builder.drizzleObjectField("instances", "activityLogs", (t) =>
+ t.connection({
+ type: ActivityLog,
+ args: { filter: t.arg({ type: ActivityLogFilter }) },
+ description:
+ "Delivery observations, newest first. " +
+ "Restricted to local instance members and administrators.",
+ select: (args, ctx, nestedSelection) =>
+ ({
+ columns: { localId: true },
+ with: {
+ activityLogs: logsConnection.getQuery(args, ctx, nestedSelection),
+ },
+ }) as const,
+ resolve: (instance, args, ctx) =>
+ logsConnection.resolve(instance.activityLogs, args, ctx, instance),
+ authScopes: (instance) => access(instance.localId),
+ }),
+);
+builder.drizzleObjectField("actors", "activityLogs", (t) =>
+ t.connection({
+ type: ActivityLog,
+ args: { filter: t.arg({ type: ActivityLogFilter }) },
+ description:
+ "Deliveries that concern this local actor, newest first: those that " +
+ "arrived at its inbox, those it sent, and those addressed to it " +
+ "through any inbox, directly or as a member of an addressed " +
+ "collection. Collection membership counts only as far as DrFed has " +
+ "stored it. Restricted to instance members and administrators.",
+ select: (args, ctx, nestedSelection) =>
+ ({
+ columns: { localId: true },
+ with: {
+ instance: { columns: { localId: true } },
+ activityLogs: logsConnection.getQuery(args, ctx, nestedSelection),
+ },
+ }) as const,
+ resolve: (actor, args, ctx) =>
+ logsConnection.resolve(actor.activityLogs, args, ctx, actor),
+ authScopes: (actor) =>
+ actor.localId == null ? false : access(actor.instance.localId),
+ }),
+);
diff --git a/packages/graphql/src/activity-log/inbound.test.ts b/packages/graphql/src/activity-log/inbound.test.ts
new file mode 100644
index 0000000..4822b50
--- /dev/null
+++ b/packages/graphql/src/activity-log/inbound.test.ts
@@ -0,0 +1,1725 @@
+// 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 .
+
+// oxlint-disable no-await-in-loop max-statements
+import assert from "node:assert/strict";
+import { it } from "node:test";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import {
+ type ObservedKeyFetch,
+ type ObservedSpan,
+ createInboundRecorder,
+ declaredKeyId,
+ hasLdSignature,
+ parseBody,
+ recordedHeaders,
+ reportedVerdict,
+} from "@drfed/graphql/activity-log";
+import createFederation, {
+ type TrackedFederation,
+} from "@drfed/graphql/federation";
+import { instanceUrl } from "@drfed/graphql/origin";
+import { type Database, addActorCollectionItem, schema } from "@drfed/models";
+import {
+ type FederationFetchOptions,
+ InProcessMessageQueue,
+ MemoryKvStore,
+ exportJwk,
+ generateCryptoKeyPair,
+ signJsonLd,
+ signObject,
+ signRequest,
+} from "@fedify/fedify";
+import { FetchError } from "@fedify/fedify/runtime";
+import {
+ Create,
+ CryptographicKey,
+ type DocumentLoader,
+ Multikey,
+ Note,
+ Person,
+} from "@fedify/vocab";
+import { eq } from "drizzle-orm";
+
+import { withTemporaryDatabase, withTestHarness } from "../harness.test.ts";
+import {
+ globalId,
+ localActorId,
+ localInstanceId,
+ remoteActorId,
+ seedAuthenticatedLocalInstance,
+ seedLocalActor,
+ seedRemoteActor,
+} from "../seed.test.ts";
+
+const origin = "https://test-instance.drfed.org";
+const localActorIri = `${origin}/users/${localActorId}`;
+const inbox = `${localActorIri}/inbox`;
+const sharedInbox = `${origin}/inbox`;
+const actorIri = new URL("https://remote.example/users/alice");
+const httpKeyId = new URL(`${actorIri.href}#main-key`);
+const ldKeyId = new URL(`${actorIri.href}#ld-key`);
+const proofKeyId = new URL(`${actorIri.href}#proof-key`);
+const rootOrigin = new URL("https://drfed.org");
+const fetchOptions = { contextData: undefined };
+
+const activity = (id: string, extra: Record = {}) => ({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: `https://remote.example/activities/${id}`,
+ type: "Create",
+ actor: actorIri.href,
+ object: { id: `https://remote.example/notes/${id}`, type: "Note" },
+ ...extra,
+});
+const post = (
+ body: string | Uint8Array,
+ url = inbox,
+ headers = {},
+) =>
+ new Request(url, {
+ method: "POST",
+ headers: { "content-type": "application/activity+json", ...headers },
+ body,
+ });
+
+function keyLoader(
+ keys: ReadonlyMap,
+): DocumentLoader {
+ return async (url) => {
+ const key = keys.get(url);
+ if (key == null && url !== actorIri.href) throw new TypeError("offline");
+ const document =
+ key == null
+ ? await new Person({
+ id: actorIri,
+ publicKeys: [...keys.values()].filter(
+ (value) => value instanceof CryptographicKey,
+ ),
+ assertionMethods: [...keys.values()].filter(
+ (value) => value instanceof Multikey,
+ ),
+ }).toJsonLd()
+ : await key.toJsonLd();
+ return { documentUrl: url, contextUrl: null, document };
+ };
+}
+
+async function createRecorder(
+ db: Database,
+ keys: ReadonlyMap = new Map(),
+ { kv = new MemoryKvStore(), documentLoader = keyLoader(keys) } = {},
+) {
+ const { contextLoader } = (await createFederation(db, { kv })).createContext(
+ new URL(inbox),
+ undefined,
+ );
+ const federation = await createFederation(db, {
+ kv,
+ contextLoaderFactory: () => contextLoader,
+ documentLoaderFactory: () => documentLoader,
+ });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
+ return {
+ contextLoader,
+ send: (request: Request) => recorder.fetch(request, fetchOptions),
+ };
+}
+const logs = (db: Database) =>
+ db.query.activityLogs.findMany({
+ orderBy: { id: "asc" },
+ with: { verificationKey: { with: { key: true } }, actorLinks: true },
+ });
+
+it("records the key of the Linked Data Signature that verified, not of the HTTP signature", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const http = await generateCryptoKeyPair();
+ const ld = await generateCryptoKeyPair();
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ ldKeyId.href,
+ new CryptographicKey({
+ id: ldKeyId,
+ owner: actorIri,
+ publicKey: ld.publicKey,
+ }),
+ ],
+ [
+ httpKeyId.href,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: http.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const signed = await signJsonLd(activity("ld"), ld.privateKey, ldKeyId, {
+ contextLoader,
+ });
+ assert.ok(hasLdSignature(signed));
+ const response = await send(
+ await signRequest(
+ post(JSON.stringify(signed)),
+ http.privateKey,
+ httpKeyId,
+ ),
+ );
+ assert.equal(response.status, 202);
+ const [log] = await logs(db);
+ assert.equal(log?.status, "received");
+ assert.equal(log?.verificationMechanism, "ld_signature");
+ assert.equal(log?.verificationResult, "verified");
+ assert.equal(log?.verificationKey?.key.iri, ldKeyId.href);
+ assert.equal(log?.signedKeyIri, httpKeyId.href);
+ assert.equal(await db.$count(schema.keys), 1);
+ const broken = { ...signed, id: "https://remote.example/activities/x" };
+ assert.equal((await send(post(JSON.stringify(broken)))).status, 401);
+ const failed = (await logs(db))[1];
+ assert.equal(failed?.status, "unverified");
+ assert.equal(failed?.verificationMechanism, "ld_signature");
+ assert.equal(failed?.verificationResult, "invalid_signature");
+ assert.equal(failed?.signedKeyIri, null);
+ });
+});
+
+it("records an Object Integrity Proof, and acknowledges a duplicate without receiving it again", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const pair = await generateCryptoKeyPair("Ed25519");
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const signed = await signObject(
+ new Create({
+ id: new URL("https://remote.example/activities/proof"),
+ actor: actorIri,
+ object: new Note({ id: new URL("https://remote.example/notes/proof") }),
+ }),
+ pair.privateKey,
+ proofKeyId,
+ { contextLoader },
+ );
+ const body = JSON.stringify(
+ await signed.toJsonLd({ format: "compact", contextLoader }),
+ );
+ assert.equal((await send(post(body))).status, 202);
+ assert.equal((await send(post(body))).status, 202);
+ const [first, second] = await logs(db);
+ for (const log of [first, second]) {
+ assert.equal(log?.verificationMechanism, "object_integrity_proof");
+ assert.equal(log?.verificationResult, "verified");
+ assert.equal(log?.verificationKey?.key.iri, proofKeyId.href);
+ assert.equal(log?.signedKeyIri, null);
+ assert.equal(log?.error, null);
+ }
+ assert.equal(first?.status, "received");
+ assert.equal(second?.status, "acknowledged");
+ assert.match(second?.responseBody ?? "", /already been processed/u);
+ });
+});
+
+it("records the key Fedify verified with, whatever a later fetch returns", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const [first, rotated] = [
+ await generateCryptoKeyPair(),
+ await generateCryptoKeyPair(),
+ ];
+ const key = ({ publicKey }: CryptoKeyPair) =>
+ new CryptographicKey({ id: httpKeyId, owner: actorIri, publicKey });
+ const modulus = async ({ publicKey }: CryptoKeyPair) =>
+ (await exportJwk(publicKey)).n;
+ // The key IRI serves the first key once, and the rotated one after.
+ const keys = new Map([[httpKeyId.href, key(first)]]);
+ let fetched = 0;
+ const serve = keyLoader(keys);
+ const kv = new MemoryKvStore();
+ const { send } = await createRecorder(db, keys, {
+ kv,
+ documentLoader: async (url) => {
+ const document = await serve(url);
+ if (url === httpKeyId.href) {
+ fetched += 1;
+ keys.set(httpKeyId.href, key(rotated));
+ }
+ return document;
+ },
+ });
+ const signed = () =>
+ signRequest(
+ post(JSON.stringify(activity(`rotated-${fetched}`))),
+ rotated.privateKey,
+ httpKeyId,
+ );
+ // Fedify fetches the first key once, and it does not verify.
+ const refused = await send(await signed());
+ assert.equal(refused.status, 401);
+ assert.equal(fetched, 1);
+ // Cached now, it fails again; Fedify refetches, and the rotated key verifies.
+ const accepted = await send(await signed());
+ assert.equal(accepted.status, 202);
+ assert.equal(fetched, 2);
+ const [refusal, acceptance] = await logs(db);
+ assert.equal(refusal?.status, "unverified");
+ assert.equal(refusal?.verificationResult, "invalid_signature");
+ assert.equal(refusal?.verificationKey?.publicKey.n, await modulus(first));
+ assert.equal(acceptance?.status, "received");
+ assert.equal(acceptance?.verificationResult, "verified");
+ assert.equal(
+ acceptance?.verificationKey?.publicKey.n,
+ await modulus(rotated),
+ );
+ });
+});
+
+it("records the key Fedify verified with, even when fetching it again failed", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const [cached, rotated] = [
+ await generateCryptoKeyPair(),
+ await generateCryptoKeyPair(),
+ ];
+ const keys = new Map([
+ [
+ httpKeyId.href,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: cached.publicKey,
+ }),
+ ],
+ ]);
+ const serve = keyLoader(keys);
+ let online = true;
+ const { send } = await createRecorder(db, keys, {
+ documentLoader: async (url) => {
+ if (!online) throw new TypeError("offline");
+ return await serve(url);
+ },
+ });
+ const signed = (id: string, { privateKey }: CryptoKeyPair) =>
+ signRequest(post(JSON.stringify(activity(id))), privateKey, httpKeyId);
+ assert.equal((await send(await signed("cached", cached))).status, 202);
+ // The cached key does not verify, and fetching it again fails.
+ online = false;
+ assert.equal((await send(await signed("offline", rotated))).status, 401);
+ const [, refusal] = await logs(db);
+ assert.equal(refusal?.status, "unverified");
+ assert.equal(refusal?.verificationResult, "key_fetch_error");
+ assert.equal(
+ refusal?.verificationKey?.publicKey.n,
+ (await exportJwk(cached.publicKey)).n,
+ );
+ });
+});
+
+it("records a signature or proof sent to an unknown inbox as unattempted", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { contextLoader, send } = await createRecorder(db);
+ const proven = await (
+ await signObject(
+ new Create({
+ id: new URL("https://remote.example/activities/unknown-proof"),
+ actor: actorIri,
+ object: new Note({
+ id: new URL("https://remote.example/notes/unknown-proof"),
+ }),
+ }),
+ (await generateCryptoKeyPair("Ed25519")).privateKey,
+ proofKeyId,
+ { contextLoader },
+ )
+ ).toJsonLd({ format: "compact", contextLoader });
+ const signed = await signJsonLd(
+ activity("unknown-ld"),
+ (await generateCryptoKeyPair()).privateKey,
+ ldKeyId,
+ { contextLoader },
+ );
+ const unknown = `${origin}/users/00000000-0000-4000-8000-000000000299/inbox`;
+ for (const document of [proven, signed]) {
+ const response = await send(post(JSON.stringify(document), unknown));
+ assert.equal(response.status, 404);
+ }
+ const recorded = await logs(db);
+ assert.equal(recorded.length, 2);
+ for (const log of recorded) {
+ assert.equal(log.verificationMechanism, null);
+ assert.equal(log.verificationResult, "unattempted");
+ assert.equal(log.verificationKey, null);
+ }
+ });
+});
+
+it("keeps a delivery whose JSON PostgreSQL cannot store, with its octets", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const pair = await generateCryptoKeyPair();
+ const { send } = await createRecorder(
+ db,
+ new Map([
+ [
+ httpKeyId.href,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const signed = [
+ JSON.stringify(activity("nul", { summary: "\u0000" })),
+ JSON.stringify(activity("surrogate", { summary: "\ud800" })),
+ ];
+ for (const body of signed) {
+ const request = await signRequest(post(body), pair.privateKey, httpKeyId);
+ assert.equal((await send(request)).status, 202);
+ }
+ // Nor does text hold NUL, whatever an activity that does not parse names.
+ const unparsed = JSON.stringify({
+ type: "Create\u0000",
+ id: `${actorIri.href}/activities/\u0000`,
+ actor: `${actorIri.href}\u0000`,
+ });
+ await send(post(unparsed));
+ const recorded = await logs(db);
+ assert.deepEqual(
+ recorded.map((log) => new TextDecoder().decode(log.body ?? undefined)),
+ [...signed, unparsed],
+ );
+ for (const log of recorded) assert.equal(log.payload, null);
+ const [nul, surrogate, raw] = recorded;
+ for (const log of [nul, surrogate]) {
+ assert.equal(log?.status, "received");
+ assert.equal(log?.verificationResult, "verified");
+ assert.equal(log?.statusCode, 202);
+ }
+ assert.equal(nul?.activityIri, "https://remote.example/activities/nul");
+ assert.deepEqual(
+ [raw?.type, raw?.types, raw?.activityIri, raw?.remoteActorIri],
+ [null, [], null, null],
+ );
+ });
+});
+
+it("records a request Fedify throws on, and throws the exception again", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const pair = await generateCryptoKeyPair();
+ const kv = new MemoryKvStore();
+ const { contextLoader } = (
+ await createFederation(db, { kv })
+ ).createContext(new URL(inbox), undefined);
+ const context = "https://remote.example/context";
+ const federation = await createFederation(db, {
+ kv,
+ // The remote context does not load, whoever asks for it.
+ contextLoaderFactory: () => (url, options) =>
+ url === context
+ ? Promise.reject(new TypeError("offline"))
+ : contextLoader(url, options),
+ documentLoaderFactory: () =>
+ keyLoader(
+ new Map([
+ [
+ httpKeyId.href,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ ),
+ });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
+ const body = JSON.stringify(
+ activity("thrown", {
+ "@context": ["https://www.w3.org/ns/activitystreams", context],
+ }),
+ );
+ const request = await signRequest(post(body), pair.privateKey, httpKeyId);
+ await assert.rejects(recorder.fetch(request, fetchOptions), {
+ name: "jsonld.InvalidUrl",
+ });
+ const [log, ...rest] = await logs(db);
+ assert.deepEqual(rest, []);
+ assert.deepEqual(
+ [log?.status, log?.statusCode, log?.responseBody],
+ ["unverified", null, null],
+ );
+ assert.match(log?.error ?? "", /^jsonld\.InvalidUrl: /u);
+ assert.equal(new TextDecoder().decode(log?.body ?? undefined), body);
+ assert.equal(log?.activityIri, "https://remote.example/activities/thrown");
+ assert.equal(log?.verificationResult, "unattempted");
+ assert.equal(log?.signedKeyIri, httpKeyId.href);
+ assert.ok(log?.completed != null);
+ });
+});
+
+it("records the key of an Object Integrity Proof that fails, and of one keyed by its full IRI", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const pair = await generateCryptoKeyPair("Ed25519");
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const sign = async (id: string) =>
+ (await (
+ await signObject(
+ new Create({
+ id: new URL(`https://remote.example/activities/${id}`),
+ actor: actorIri,
+ object: new Note({
+ id: new URL(`https://remote.example/notes/${id}`),
+ content: "original",
+ }),
+ }),
+ pair.privateKey,
+ proofKeyId,
+ { contextLoader },
+ )
+ ).toJsonLd({ format: "compact", contextLoader })) as Record<
+ string,
+ unknown
+ >;
+ const tampered = await sign("tampered");
+ const object = tampered.object as Record;
+ tampered.object = { ...object, content: "tampered" };
+ assert.equal((await send(post(JSON.stringify(tampered)))).status, 401);
+ const { proof, ...expanded } = await sign("expanded");
+ const full = { ...expanded, "https://w3id.org/security#proof": proof };
+ assert.equal((await send(post(JSON.stringify(full)))).status, 202);
+ const [failed, verified] = await logs(db);
+ assert.equal(failed?.verificationMechanism, "object_integrity_proof");
+ assert.equal(failed?.verificationResult, "invalid_signature");
+ assert.equal(failed?.verificationKey?.key.iri, proofKeyId.href);
+ assert.equal(verified?.status, "received");
+ assert.equal(verified?.verificationMechanism, "object_integrity_proof");
+ assert.equal(verified?.verificationResult, "verified");
+ assert.equal(verified?.verificationKey?.key.iri, proofKeyId.href);
+ });
+});
+
+it("tells a key that could not be fetched from a signature that did not verify", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const rsa = await generateCryptoKeyPair();
+ const ed = await generateCryptoKeyPair("Ed25519");
+ // Every key is of a server that fails as the fragment of its IRI says.
+ const keyIri = (mechanism: string, failure: string) =>
+ new URL(`${actorIri.href}#${mechanism}-${failure}`);
+ const { contextLoader, send } = await createRecorder(db, new Map(), {
+ documentLoader: async (url) => {
+ if (url.endsWith("-gone")) {
+ throw new FetchError(url, "Gone", new Response("", { status: 410 }));
+ }
+ if (!url.endsWith("-invalid")) throw new TypeError("offline");
+ const document = await new Note({ id: new URL(url) }).toJsonLd();
+ return { documentUrl: url, contextUrl: null, document };
+ },
+ });
+ const requests = {
+ ld_signature: async (id: string, key: URL) =>
+ post(
+ JSON.stringify(
+ await signJsonLd(activity(id), rsa.privateKey, key, {
+ contextLoader,
+ }),
+ ),
+ ),
+ object_integrity_proof: async (id: string, key: URL) => {
+ const signed = await signObject(
+ new Create({
+ id: new URL(`https://remote.example/activities/${id}`),
+ actor: actorIri,
+ object: new Note({
+ id: new URL(`https://remote.example/notes/${id}`),
+ }),
+ }),
+ ed.privateKey,
+ key,
+ { contextLoader },
+ );
+ return post(
+ JSON.stringify(
+ await signed.toJsonLd({ format: "compact", contextLoader }),
+ ),
+ );
+ },
+ http_signature: (id: string, key: URL) =>
+ signRequest(post(JSON.stringify(activity(id))), rsa.privateKey, key),
+ };
+ // Fedify names why an HTTP signature's key was not fetched itself.
+ const causes = {
+ ld_signature: ["network_error", "cached", "410", "invalid"],
+ object_integrity_proof: ["network_error", "cached", "410", "invalid"],
+ http_signature: ["TypeError", "TypeError", "410", "invalid"],
+ };
+ for (const mechanism of [
+ "ld_signature",
+ "object_integrity_proof",
+ "http_signature",
+ ] as const) {
+ const sign = requests[mechanism];
+ // The second request finds the failure of the first in the cache.
+ for (const [index, failure] of [
+ "offline",
+ "offline",
+ "gone",
+ "invalid",
+ ].entries()) {
+ const id = `${mechanism}-${failure}-${index}`;
+ const response = await send(await sign(id, keyIri(mechanism, failure)));
+ assert.equal(response.status, 401, id);
+ const log = (await logs(db)).at(-1);
+ assert.equal(log?.status, "unverified", id);
+ assert.equal(log?.verificationMechanism, mechanism, id);
+ assert.equal(log?.verificationResult, "key_fetch_error", id);
+ assert.equal(log?.verificationKey, null, id);
+ assert.equal(
+ log?.error,
+ `keyFetchError: ${causes[mechanism][index]}`,
+ id,
+ );
+ }
+ }
+ });
+});
+
+it("records the key a signature or proof failed with, when fetching it again failed", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const rsa = [await generateCryptoKeyPair(), await generateCryptoKeyPair()];
+ const ed = [
+ await generateCryptoKeyPair("Ed25519"),
+ await generateCryptoKeyPair("Ed25519"),
+ ];
+ const keys = new Map([
+ [
+ ldKeyId.href,
+ new CryptographicKey({
+ id: ldKeyId,
+ owner: actorIri,
+ publicKey: rsa[0]!.publicKey,
+ }),
+ ],
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: ed[0]!.publicKey,
+ }),
+ ],
+ ]);
+ const serve = keyLoader(keys);
+ let online = true;
+ const { contextLoader, send } = await createRecorder(db, keys, {
+ documentLoader: async (url) => {
+ if (!online) throw new TypeError("offline");
+ return await serve(url);
+ },
+ });
+ const ld = async (id: string, { privateKey }: CryptoKeyPair) =>
+ post(
+ JSON.stringify(
+ await signJsonLd(activity(id), privateKey, ldKeyId, {
+ contextLoader,
+ }),
+ ),
+ );
+ const proof = async (id: string, { privateKey }: CryptoKeyPair) => {
+ const signed = await signObject(
+ new Create({
+ id: new URL(`https://remote.example/activities/${id}`),
+ actor: actorIri,
+ object: new Note({
+ id: new URL(`https://remote.example/notes/${id}`),
+ }),
+ }),
+ privateKey,
+ proofKeyId,
+ { contextLoader },
+ );
+ return post(
+ JSON.stringify(
+ await signed.toJsonLd({ format: "compact", contextLoader }),
+ ),
+ );
+ };
+ assert.equal((await send(await ld("ld-cached", rsa[0]!))).status, 202);
+ assert.equal((await send(await proof("proof-cached", ed[0]!))).status, 202);
+ // The cached keys do not verify, and fetching them again fails.
+ online = false;
+ assert.equal((await send(await ld("ld-offline", rsa[1]!))).status, 401);
+ assert.equal(
+ (await send(await proof("proof-offline", ed[1]!))).status,
+ 401,
+ );
+ const [, , ldRefusal, proofRefusal] = await logs(db);
+ for (const [refusal, mechanism, keyId] of [
+ [ldRefusal, "ld_signature", ldKeyId],
+ [proofRefusal, "object_integrity_proof", proofKeyId],
+ ] as const) {
+ assert.equal(refusal?.verificationMechanism, mechanism);
+ assert.equal(refusal?.verificationResult, "key_fetch_error");
+ assert.equal(refusal?.error, "keyFetchError: network_error");
+ assert.equal(refusal?.verificationKey?.key.iri, keyId.href);
+ }
+ assert.equal(
+ ldRefusal?.verificationKey?.publicKey.n,
+ (await exportJwk(rsa[0]!.publicKey)).n,
+ );
+ });
+});
+
+it("records the key each verification used, not one another found under the same IRI", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const rsa = await generateCryptoKeyPair();
+ const ed = await generateCryptoKeyPair("Ed25519");
+ // The proof and the HTTP signature name one IRI, which serves a Multikey.
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: ed.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const signed = (await (
+ await signObject(
+ new Create({
+ id: new URL("https://remote.example/activities/shared-iri"),
+ actor: actorIri,
+ object: new Note({
+ id: new URL("https://remote.example/notes/shared-iri"),
+ content: "original",
+ }),
+ }),
+ ed.privateKey,
+ proofKeyId,
+ { contextLoader },
+ )
+ ).toJsonLd({ format: "compact", contextLoader })) as Record<
+ string,
+ unknown
+ >;
+ const object = signed.object as Record;
+ const tampered = { ...signed, object: { ...object, content: "tampered" } };
+ const request = await signRequest(
+ post(JSON.stringify(tampered)),
+ rsa.privateKey,
+ proofKeyId,
+ );
+ assert.equal((await send(request)).status, 401);
+ const [log] = await logs(db);
+ assert.equal(log?.status, "unverified");
+ assert.equal(log?.verificationMechanism, "http_signature");
+ assert.equal(log?.verificationResult, "key_fetch_error");
+ assert.equal(log?.error, "keyFetchError: invalid");
+ assert.equal(log?.signedKeyIri, proofKeyId.href);
+ // The proof read the Ed25519 key; the HTTP signature found none to use.
+ assert.equal(log?.verificationKey, null);
+ // A Linked Data Signature and the HTTP signature name one key, too.
+ const [cached, rotated] = [
+ await generateCryptoKeyPair(),
+ await generateCryptoKeyPair(),
+ ];
+ const keys = new Map([
+ [
+ httpKeyId.href,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: cached.publicKey,
+ }),
+ ],
+ ]);
+ const serve = keyLoader(keys);
+ let online = true;
+ const shared = await createRecorder(db, keys, {
+ documentLoader: async (url) => {
+ if (!online) throw new TypeError("offline");
+ return await serve(url);
+ },
+ });
+ const ld = async (id: string, { privateKey }: CryptoKeyPair) =>
+ post(
+ JSON.stringify(
+ await signJsonLd(activity(id), privateKey, httpKeyId, {
+ contextLoader: shared.contextLoader,
+ }),
+ ),
+ );
+ const accepted = await shared.send(await ld("shared-cached", cached));
+ assert.equal(accepted.status, 202);
+ // The former fails with the cached key, and empties the entry fetching it
+ // again; the latter then finds no key, and so uses none.
+ online = false;
+ const both = await signRequest(
+ await ld("shared-offline", rotated),
+ rotated.privateKey,
+ httpKeyId,
+ );
+ assert.equal((await shared.send(both)).status, 401);
+ const refusal = (await logs(db)).at(-1);
+ assert.equal(refusal?.verificationMechanism, "http_signature");
+ assert.equal(refusal?.verificationResult, "key_fetch_error");
+ assert.equal(refusal?.signedKeyIri, httpKeyId.href);
+ assert.equal(refusal?.verificationKey, null);
+ });
+});
+
+it("records a proof that verified as verified, though it does not authenticate the actor", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const rsa = await generateCryptoKeyPair();
+ const ed = await generateCryptoKeyPair("Ed25519");
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: ed.publicKey,
+ }),
+ ],
+ ]),
+ );
+ // Alice's key signs an activity that names Bob as its actor.
+ const prove = async (id: string) =>
+ JSON.stringify(
+ await (
+ await signObject(
+ new Create({
+ id: new URL(`https://remote.example/activities/${id}`),
+ actor: new URL("https://remote.example/users/bob"),
+ object: new Note({
+ id: new URL(`https://remote.example/notes/${id}`),
+ }),
+ }),
+ ed.privateKey,
+ proofKeyId,
+ { contextLoader },
+ )
+ ).toJsonLd({ format: "compact", contextLoader }),
+ );
+ assert.equal((await send(post(await prove("bob")))).status, 401);
+ // Nor does an HTTP signature that fails make the proof one that failed.
+ const unsigned = await signRequest(
+ post(await prove("bob-http")),
+ rsa.privateKey,
+ httpKeyId,
+ );
+ assert.equal((await send(unsigned)).status, 401);
+ const recorded = await logs(db);
+ assert.equal(recorded.length, 2);
+ for (const log of recorded) {
+ assert.equal(log.status, "rejected");
+ assert.equal(log.verificationMechanism, "object_integrity_proof");
+ assert.equal(log.verificationResult, "verified");
+ assert.equal(log.verificationKey?.key.iri, proofKeyId.href);
+ assert.equal(log.verificationKey?.publicKey.kty, "OKP");
+ assert.match(log.error ?? "", /did not accept them as authenticating/u);
+ }
+ assert.deepEqual(
+ recorded.map((log) => log.signedKeyIri),
+ [null, httpKeyId.href],
+ );
+ });
+});
+
+it("receives a queued activity once the queue worker runs its listener", async () => {
+ for (const workerFirst of [false, true]) {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const pair = await generateCryptoKeyPair("Ed25519");
+ const kv = new MemoryKvStore();
+ const { contextLoader } = (
+ await createFederation(db, { kv })
+ ).createContext(new URL(inbox), undefined);
+ const federation = await createFederation(db, {
+ kv,
+ queue: new InProcessMessageQueue(),
+ manuallyStartQueue: true,
+ contextLoaderFactory: () => contextLoader,
+ documentLoaderFactory: () =>
+ keyLoader(
+ new Map([
+ [
+ proofKeyId.href,
+ new Multikey({
+ id: proofKeyId,
+ controller: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ ),
+ });
+ const controller = new AbortController();
+ const start = () =>
+ federation.startQueue(undefined, { signal: controller.signal });
+ // Holding the recorder back lets the worker finish before the log exists.
+ const { promise: held, resolve: release } = Promise.withResolvers();
+ const recorder = createInboundRecorder({
+ db: workerFirst
+ ? new Proxy(db, {
+ get(target, property) {
+ const value: unknown = Reflect.get(target, property, target);
+ if (property !== "transaction" || typeof value !== "function") {
+ return value;
+ }
+ return async (...args: unknown[]) => {
+ await held;
+ return (value as (...values: unknown[]) => unknown).apply(
+ target,
+ args,
+ );
+ };
+ },
+ })
+ : db,
+ federation,
+ rootOrigin,
+ });
+ const signed = await signObject(
+ new Create({
+ id: new URL("https://remote.example/activities/queued"),
+ actor: actorIri,
+ object: new Note({ id: new URL("https://remote.example/notes/q") }),
+ }),
+ pair.privateKey,
+ proofKeyId,
+ { contextLoader },
+ );
+ const body = JSON.stringify(
+ await signed.toJsonLd({ format: "compact", contextLoader }),
+ );
+ const worker = workerFirst ? start() : undefined;
+ const response = recorder.fetch(post(body), fetchOptions);
+ if (workerFirst) {
+ await sleep(300);
+ release();
+ }
+ assert.equal((await response).status, 202);
+ const running = worker ?? start();
+ try {
+ let status: string | undefined;
+ for (let tries = 0; tries < 300 && status !== "received"; tries += 1) {
+ await sleep(10);
+ status = (await db.query.activityLogs.findFirst())?.status;
+ }
+ assert.equal(status, "received", `worker first: ${workerFirst}`);
+ } finally {
+ controller.abort();
+ await running;
+ }
+ });
+ }
+});
+
+it("touches neither the database nor the body of a request that is not an inbox POST", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const kv = new MemoryKvStore();
+ const base = await createFederation(db, { kv });
+ let queries = 0;
+ const counting = new Proxy(db, {
+ get(target, property) {
+ queries += 1;
+ return Reflect.get(target, property);
+ },
+ });
+ const passThrough = new Response("passed through");
+ const federation = {
+ createContext: base.createContext.bind(base),
+ fetch: () => Promise.resolve(passThrough),
+ } as unknown as TrackedFederation;
+ const recorder = createInboundRecorder({
+ db: counting,
+ federation,
+ rootOrigin,
+ });
+ const requests = [
+ new Request(localActorIri),
+ new Request(inbox),
+ post("{}", `${origin}/users/${localActorId}/outbox`),
+ ];
+ for (const request of requests) {
+ assert.equal(await recorder.fetch(request, fetchOptions), passThrough);
+ assert.equal(request.bodyUsed, false);
+ }
+ assert.equal(queries, 0);
+ assert.equal(await db.$count(schema.activityLogs), 0);
+ });
+});
+
+it("keeps the received octets and headers while parsing a payload for querying", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { send } = await createRecorder(db);
+ const body = `{ "@context": "https://www.w3.org/ns/activitystreams", "id": "urn:dup", "type":"Create", "actor": "${actorIri.href}", "summary": "a", "summary": "b" }`;
+ assert.equal(
+ (
+ await send(
+ post(body, inbox, {
+ cookie: "session=secret",
+ authorization: "Bearer secret",
+ }),
+ )
+ ).status,
+ 401,
+ );
+ const invalidUtf8 = new Uint8Array([0x7b, 0xff, 0x7d]);
+ assert.equal((await send(post(invalidUtf8))).status, 400);
+ const [duplicated, binary] = await logs(db);
+ assert.equal(new TextDecoder().decode(duplicated?.body ?? undefined), body);
+ assert.deepEqual(duplicated?.payload, {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: "urn:dup",
+ type: "Create",
+ actor: actorIri.href,
+ summary: "b",
+ });
+ const headers = new Map(duplicated?.headers);
+ assert.equal(headers.get("content-type"), "application/activity+json");
+ assert.equal(headers.get("authorization"), "Bearer");
+ assert.equal(headers.has("cookie"), false);
+ assert.deepEqual(new Uint8Array(binary?.body ?? []), invalidUtf8);
+ assert.equal(binary?.statusCode, 400);
+ assert.equal(binary?.status, "unverified");
+ assert.equal(binary?.responseBody, binary?.error);
+ });
+ assert.equal(parseBody(new TextEncoder().encode("nope")), undefined);
+ assert.equal(parseBody(new TextEncoder().encode("null")), null);
+ const signature =
+ 'Signature keyId="https://remote.example/key",headers="date"';
+ assert.deepEqual(
+ recordedHeaders(new Headers({ Authorization: signature, Cookie: "a=b" })),
+ [["authorization", signature]],
+ );
+ assert.equal(
+ declaredKeyId(new Headers({ authorization: signature })),
+ "https://remote.example/key",
+ );
+ assert.equal(
+ declaredKeyId(
+ new Headers({ "signature-input": 'sig1=("@method");keyid="urn:k"' }),
+ ),
+ "urn:k",
+ );
+ assert.equal(declaredKeyId(new Headers({ authorization: "Bearer x" })), null);
+});
+
+it("reads the verification out of what Fedify reports", () => {
+ const span = (
+ name: string,
+ attributes: Record = {},
+ { failed = false, events = [] as ObservedSpan["events"] } = {},
+ ): ObservedSpan => ({
+ name,
+ attributes: new Map(Object.entries(attributes)),
+ events,
+ failed,
+ });
+ const measured = (
+ kind: string,
+ result: string,
+ keyFetches: readonly ObservedKeyFetch[] = [],
+ ) => ({ kind, result, keyFetches });
+ const none = { ldKeyIri: null, proofMethods: [] };
+ const proofKey = proofKeyId.href;
+ const noSignature = span("http_signatures.verify", {
+ "http_signatures.verified": false,
+ "http_signatures.failure_reason": "noSignature",
+ });
+ // Answered before verifying, e.g. a body that is not JSON.
+ assert.deepEqual(reportedVerdict({ spans: [], verifications: [] }, none), {
+ mechanism: null,
+ result: "unattempted",
+ });
+ // Linked Data Signatures verified, even if the activity then did not parse.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span("ld_signatures.verify", {
+ "ld_signatures.key_id": ldKeyId.href,
+ }),
+ ],
+ verifications: [measured("linked_data", "verified")],
+ },
+ none,
+ ),
+ {
+ mechanism: "ld_signature",
+ result: "verified",
+ keyIri: ldKeyId.href,
+ keyFetches: [],
+ detail: null,
+ },
+ );
+ // A proof that failed before HTTP signatures were found missing.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span(
+ "object_integrity_proofs.verify",
+ { "object_integrity_proofs.key_id": proofKey },
+ { failed: true },
+ ),
+ noSignature,
+ ],
+ verifications: [
+ measured("object_integrity", "rejected"),
+ measured("http", "missing"),
+ ],
+ },
+ none,
+ ),
+ {
+ mechanism: "object_integrity_proof",
+ result: "invalid_signature",
+ keyIri: proofKey,
+ keyFetches: [],
+ detail: null,
+ },
+ );
+ // Answered before verifying, a signature or proof carried was not tried.
+ assert.deepEqual(
+ reportedVerdict(
+ { spans: [], verifications: [] },
+ { ldKeyIri: ldKeyId.href, proofMethods: [proofKey] },
+ ),
+ { mechanism: null, result: "unattempted" },
+ );
+ // A proof Fedify gave up on without a report counts as tried once it went
+ // on to HTTP signatures.
+ assert.deepEqual(
+ reportedVerdict(
+ { spans: [noSignature], verifications: [measured("http", "missing")] },
+ { ldKeyIri: null, proofMethods: [null, proofKey] },
+ ),
+ {
+ mechanism: "object_integrity_proof",
+ result: "invalid_signature",
+ keyIri: proofKey,
+ keyFetches: [],
+ detail: null,
+ },
+ );
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span("http_signatures.verify", {
+ "http_signatures.verified": false,
+ "http_signatures.failure_reason": "keyFetchError",
+ "http_signatures.key_id": httpKeyId.href,
+ "http_signatures.key_fetch_status": 410,
+ }),
+ ],
+ verifications: [measured("http", "rejected")],
+ },
+ none,
+ ),
+ {
+ mechanism: "http_signature",
+ result: "key_fetch_error",
+ keyIri: httpKeyId.href,
+ keyFetches: [],
+ detail: "keyFetchError: 410",
+ },
+ );
+ // No usable key came back, so nothing was checked: Fedify counted why.
+ const fetched = (
+ result: string,
+ lookup: string,
+ statusCode?: number,
+ ): ObservedKeyFetch => ({
+ result,
+ lookup: { result: lookup, statusCode: statusCode ?? null },
+ keys: new Map(),
+ });
+ const ldSpan = span("ld_signatures.verify", {
+ "ld_signatures.key_id": ldKeyId.href,
+ });
+ for (const [keyFetches, result, detail] of [
+ [[fetched("error", "network_error")], "key_fetch_error", "network_error"],
+ [[fetched("error", "error", 503)], "key_fetch_error", "503"],
+ [[fetched("error", "not_found", 404)], "key_fetch_error", "404"],
+ [[fetched("error", "invalid")], "key_fetch_error", "invalid"],
+ // The record of an earlier failure, found in the cache.
+ [[fetched("error", "hit")], "key_fetch_error", "cached"],
+ [
+ [{ result: "error", lookup: null, keys: new Map() }],
+ "key_fetch_error",
+ "error",
+ ],
+ // The cached key did not verify, and fetching it again failed.
+ [
+ [fetched("hit", "hit"), fetched("error", "network_error")],
+ "key_fetch_error",
+ "network_error",
+ ],
+ // The cached key did not verify, nor did the one fetched again.
+ [
+ [fetched("hit", "hit"), fetched("fetched", "fetched")],
+ "invalid_signature",
+ null,
+ ],
+ [[], "invalid_signature", null],
+ ] as const) {
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [ldSpan, noSignature],
+ verifications: [
+ measured("linked_data", "rejected", keyFetches),
+ measured("http", "missing"),
+ ],
+ },
+ none,
+ ),
+ {
+ mechanism: "ld_signature",
+ result,
+ keyIri: ldKeyId.href,
+ keyFetches,
+ detail: detail == null ? null : `keyFetchError: ${detail}`,
+ },
+ );
+ }
+ // Fedify stops at the first proof that fails: the fetch of the last counts.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [noSignature],
+ verifications: [
+ measured("object_integrity", "verified", [
+ fetched("fetched", "fetched"),
+ ]),
+ measured("object_integrity", "rejected", [
+ fetched("error", "error", 500),
+ ]),
+ measured("http", "missing"),
+ ],
+ },
+ { ldKeyIri: null, proofMethods: [proofKey] },
+ ),
+ {
+ mechanism: "object_integrity_proof",
+ result: "key_fetch_error",
+ keyIri: proofKey,
+ keyFetches: [fetched("error", "error", 500)],
+ detail: "keyFetchError: 500",
+ },
+ );
+ // A proof that verified without authenticating the activity is no proof that
+ // failed, whatever Fedify found of the HTTP signature it went on to.
+ const verifiedProof = span("object_integrity_proofs.verify", {
+ "object_integrity_proofs.key_id": proofKey,
+ });
+ const proofMeasured = measured("object_integrity", "verified", [
+ fetched("hit", "hit"),
+ ]);
+ const proven = { ldKeyIri: null, proofMethods: [proofKey] };
+ for (const [http, httpMeasured] of [
+ [noSignature, measured("http", "missing")],
+ [
+ span("http_signatures.verify", {
+ "http_signatures.verified": false,
+ "http_signatures.failure_reason": "invalidSignature",
+ "http_signatures.key_id": httpKeyId.href,
+ }),
+ measured("http", "rejected", [fetched("error", "invalid")]),
+ ],
+ ] as const) {
+ const { detail, ...verdict } = reportedVerdict(
+ {
+ spans: [verifiedProof, http],
+ verifications: [proofMeasured, httpMeasured],
+ },
+ proven,
+ );
+ assert.deepEqual(verdict, {
+ mechanism: "object_integrity_proof",
+ result: "verified",
+ keyIri: proofKey,
+ keyFetches: [fetched("hit", "hit")],
+ });
+ assert.match(detail ?? "", /did not accept them as authenticating/u);
+ }
+ // An HTTP signature that then verified is the verdict, with its own fetches.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ verifiedProof,
+ span("http_signatures.verify", {
+ "http_signatures.verified": true,
+ "http_signatures.key_id": httpKeyId.href,
+ }),
+ ],
+ verifications: [
+ proofMeasured,
+ measured("http", "verified", [fetched("fetched", "fetched")]),
+ ],
+ },
+ proven,
+ ),
+ {
+ mechanism: "http_signature",
+ result: "verified",
+ keyIri: httpKeyId.href,
+ keyFetches: [fetched("fetched", "fetched")],
+ },
+ );
+ // Fedify calls a key document without a key an invalid signature.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span("http_signatures.verify", {
+ "http_signatures.verified": false,
+ "http_signatures.failure_reason": "invalidSignature",
+ "http_signatures.key_id": httpKeyId.href,
+ }),
+ ],
+ verifications: [
+ measured("http", "rejected", [fetched("error", "invalid")]),
+ ],
+ },
+ none,
+ ),
+ {
+ mechanism: "http_signature",
+ result: "key_fetch_error",
+ keyIri: httpKeyId.href,
+ keyFetches: [fetched("error", "invalid")],
+ detail: "keyFetchError: invalid",
+ },
+ );
+ // HTTP signature verification that threw.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span(
+ "http_signatures.verify",
+ { "http_signatures.key_id": httpKeyId.href },
+ { failed: true },
+ ),
+ ],
+ verifications: [measured("http", "error")],
+ },
+ none,
+ ),
+ {
+ mechanism: "http_signature",
+ result: "invalid_signature",
+ keyIri: httpKeyId.href,
+ keyFetches: [],
+ detail: "Fedify could not verify the HTTP signature.",
+ },
+ );
+ // Fedify accepts an activity naming no actor, having nothing to verify.
+ assert.deepEqual(
+ reportedVerdict(
+ {
+ spans: [
+ span(
+ "activitypub.inbox",
+ {},
+ {
+ events: [
+ {
+ name: "activitypub.activity.received",
+ attributes: {
+ "activitypub.activity.verified": true,
+ "ld_signatures.verified": false,
+ "http_signatures.verified": false,
+ },
+ },
+ ],
+ },
+ ),
+ ],
+ verifications: [],
+ },
+ none,
+ ),
+ { mechanism: null, result: "no_signature" },
+ );
+});
+
+it("stores the canonical inbox IRI apart from the URL a request arrived at", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { send } = await createRecorder(db);
+ const unknown = "00000000-0000-4000-8000-000000000299";
+ const arrivals = [
+ `http://test-instance.drfed.org./users/${localActorId}/inbox`,
+ "http://test-instance.drfed.org./inbox",
+ `http://test-instance.drfed.org.:443/users/${unknown}/inbox`,
+ ];
+ for (const url of arrivals) {
+ await send(post(JSON.stringify(activity("canonical")), url));
+ }
+ assert.deepEqual(
+ (await logs(db)).map((log) => [log.inboxUrl, log.requestUrl]),
+ [
+ [inbox, arrivals[0]],
+ [sharedInbox, arrivals[1]],
+ [`${origin}/users/${unknown}/inbox`, arrivals[2]],
+ ],
+ );
+ });
+ assert.equal(
+ instanceUrl(
+ new URL("http://drfed.localhost:8888"),
+ "a.drfed.localhost:8888",
+ "/inbox",
+ ).href,
+ "http://a.drfed.localhost:8888/inbox",
+ );
+});
+
+it("keeps IRIs that are not URLs out of URL fields, but in the request", async () => {
+ await withTestHarness(async ({ db, post: query }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const { send } = await createRecorder(db);
+ const body = JSON.stringify({
+ type: "Create",
+ id: "not a url",
+ actor: "not an actor url",
+ });
+ const signature =
+ 'keyId="not a key",headers="(request-target)",signature="AAAA"';
+ await send(post(body, inbox, { signature }));
+ const result = await (
+ await query(
+ {
+ query: `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node {
+ activityIri remoteActorIri signedKeyIri remoteHost payload rawBody requestHeaders
+ } } } } } }`,
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ const { node } = result.data.node.activityLogs.edges[0];
+ assert.deepEqual(
+ [node.activityIri, node.remoteActorIri, node.signedKeyIri],
+ [null, null, null],
+ );
+ assert.equal(node.remoteHost, null);
+ assert.equal(node.payload.id, "not a url");
+ assert.equal(node.payload.actor, "not an actor url");
+ assert.equal(node.rawBody, body);
+ assert.equal(
+ new Map(node.requestHeaders).get("signature"),
+ signature,
+ );
+ });
+});
+
+it("orders logs by arrival, even when handling ends out of order", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const kv = new MemoryKvStore();
+ const { contextLoader } = (
+ await createFederation(db, { kv })
+ ).createContext(new URL(inbox), undefined);
+ const federation = await createFederation(db, {
+ kv,
+ contextLoaderFactory: () => contextLoader,
+ documentLoaderFactory: () => () =>
+ Promise.reject(new TypeError("offline")),
+ });
+ const { promise: reached, resolve: enter } = Promise.withResolvers();
+ const { promise: gate, resolve: release } = Promise.withResolvers();
+ const recorder = createInboundRecorder({
+ db,
+ rootOrigin,
+ federation: new Proxy(federation, {
+ get(target, property) {
+ if (property === "fetch") {
+ return async (
+ request: Request,
+ options: FederationFetchOptions,
+ ) => {
+ if (request.headers.has("x-slow")) {
+ enter();
+ await gate;
+ }
+ return await target.fetch(request, options);
+ };
+ }
+ const value: unknown = Reflect.get(target, property, target);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ }),
+ });
+ const [slow, fast] = [activity("slow"), activity("fast")];
+ const slowResponse = recorder.fetch(
+ post(JSON.stringify(slow), inbox, { "x-slow": "1" }),
+ fetchOptions,
+ );
+ await reached;
+ await recorder.fetch(post(JSON.stringify(fast)), fetchOptions);
+ release();
+ await slowResponse;
+ const iris = async (orderBy: { created: "asc" } | { id: "asc" }) =>
+ (await db.query.activityLogs.findMany({ orderBy })).map(
+ (log) => log.activityIri,
+ );
+ // Both the time and the ID of a log are chosen as its request arrives.
+ assert.deepEqual(await iris({ created: "asc" }), [slow.id, fast.id]);
+ assert.deepEqual(await iris({ id: "asc" }), [slow.id, fast.id]);
+ const [first, second] = await db.query.activityLogs.findMany({
+ orderBy: { created: "asc" },
+ });
+ assert.ok(Temporal.Instant.compare(first!.completed!, first!.created) >= 0);
+ assert.ok(
+ Temporal.Instant.compare(first!.completed!, second!.completed!) > 0,
+ );
+ });
+});
+
+it("relates a log to every local actor it concerns, once", async () => {
+ await withTestHarness(async ({ db, post: query }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ await seedRemoteActor(db);
+ const { send } = await createRecorder(db);
+ const followers = "https://remote.example.com/users/bob/followers";
+ const following = "https://remote.example.com/users/bob/following";
+ await addActorCollectionItem(db, remoteActorId, "followers", localActorId);
+ const deliveries: [string, string, Record][] = [
+ ["own", inbox, { to: localActorIri }],
+ ["shared", sharedInbox, { to: localActorIri, cc: [localActorIri] }],
+ ["object", sharedInbox, { to: { id: localActorIri, type: "Person" } }],
+ ["array", sharedInbox, { bcc: ["urn:other", localActorIri] }],
+ ["members", sharedInbox, { cc: followers }],
+ ["both", sharedInbox, { to: [localActorIri], cc: [followers] }],
+ ["empty", sharedInbox, { cc: following }],
+ ["public", sharedInbox, { to: "as:Public" }],
+ [
+ "typed",
+ sharedInbox,
+ { type: ["Create", "https://example.com/ns#Custom"] },
+ ],
+ ];
+ for (const [id, url, extra] of deliveries) {
+ await send(post(JSON.stringify(activity(id, extra)), url));
+ }
+ const rows = await logs(db);
+ assert.deepEqual(
+ rows.map((log) => [
+ log.activityIri?.split("/").at(-1),
+ log.actorId,
+ log.actorLinks.map((link) => [
+ link.actorId,
+ link.inboxOwner,
+ link.addressed,
+ link.viaCollectionIri,
+ ]),
+ ]),
+ [
+ ["own", localActorId, [[localActorId, true, true, null]]],
+ ["shared", null, [[localActorId, false, true, null]]],
+ ["object", null, [[localActorId, false, true, null]]],
+ ["array", null, [[localActorId, false, true, null]]],
+ ["members", null, [[localActorId, false, true, followers]]],
+ ["both", null, [[localActorId, false, true, null]]],
+ ["empty", null, []],
+ ["public", null, []],
+ ["typed", null, []],
+ ],
+ );
+ assert.deepEqual(rows.at(-1)?.types, [
+ "Create",
+ "https://example.com/ns#Custom",
+ ]);
+ assert.equal(rows.at(-1)?.type, "Create");
+ const read = async () => {
+ const result = await (
+ await query(
+ {
+ query: `{
+ actor: node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 20) { edges { node { activityIri } } } } }
+ instance: node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 20) { edges { node { activityIri actor { uuid } } } } } }
+ }`,
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ return result.data;
+ };
+ const names = (connection: {
+ edges: { node: { activityIri: string } }[];
+ }) =>
+ connection.edges.map((edge) => edge.node.activityIri.split("/").at(-1));
+ const before = await read();
+ assert.deepEqual(names(before.actor.activityLogs), [
+ "both",
+ "members",
+ "array",
+ "object",
+ "shared",
+ "own",
+ ]);
+ assert.equal(before.instance.activityLogs.edges.length, deliveries.length);
+ assert.equal(
+ before.instance.activityLogs.edges.at(-1).node.actor.uuid,
+ localActorId,
+ );
+ await db
+ .update(schema.actors)
+ .set({ deleted: Temporal.Now.instant() })
+ .where(eq(schema.actors.id, localActorId));
+ const after = await read();
+ assert.equal(after.actor, null);
+ assert.equal(after.instance.activityLogs.edges.length, deliveries.length);
+ assert.equal(after.instance.activityLogs.edges.at(-1).node.actor, null);
+ });
+});
+
+it("exposes what was observed, and pages through the versions of a key", async () => {
+ await withTestHarness(async ({ db, post: query }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const pairs = [
+ await generateCryptoKeyPair(),
+ await generateCryptoKeyPair(),
+ await generateCryptoKeyPair(),
+ ];
+ const keys = new Map();
+ const { send } = await createRecorder(db, keys);
+ const kvPrefix = httpKeyId.href;
+ const body = JSON.stringify(activity("fields"));
+ for (const pair of pairs) {
+ keys.set(
+ kvPrefix,
+ new CryptographicKey({
+ id: httpKeyId,
+ owner: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ );
+ const signed = await signRequest(post(body), pair.privateKey, httpKeyId);
+ assert.equal((await send(signed)).status, 202);
+ }
+ const fields = `uuid status verificationMechanism verificationResult types
+ rawBody rawBodyBase64 requestHeaders requestUrl inboxUrl responseBody
+ recipientIris error signedKeyIri`;
+ const result = await (
+ await query(
+ {
+ query: `query($after: String) { node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node {
+ ${fields}
+ verificationKey { key { versions(first: 2, after: $after) { edges { node { uuid fingerprint } } pageInfo { hasNextPage endCursor } } } }
+ } } } } } }`,
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ const { node } = result.data.node.activityLogs.edges[0];
+ assert.equal(node.status, "acknowledged");
+ assert.equal(node.verificationMechanism, "http_signature");
+ assert.equal(node.verificationResult, "verified");
+ assert.deepEqual(node.types, ["Create"]);
+ assert.equal(node.rawBody, body);
+ assert.equal(atob(node.rawBodyBase64), body);
+ assert.ok(new Map(node.requestHeaders).has("signature"));
+ assert.equal(node.requestUrl, inbox);
+ assert.equal(node.inboxUrl, inbox);
+ assert.deepEqual(node.recipientIris, []);
+ assert.equal(node.signedKeyIri, httpKeyId.href);
+ const stored = await db.query.keyVersions.findMany({
+ orderBy: { firstSeen: "asc", id: "asc" },
+ });
+ assert.equal(stored.length, 3);
+ const firstPage = node.verificationKey.key.versions;
+ assert.deepEqual(
+ firstPage.edges.map((edge: { node: { uuid: string } }) => edge.node.uuid),
+ stored.slice(0, 2).map((version) => version.id),
+ );
+ assert.equal(firstPage.pageInfo.hasNextPage, true);
+ const next = await (
+ await query(
+ {
+ query: `query($after: String) { node(id: "${Buffer.from(`Key:${stored[0]!.keyId}`).toString("base64")}") { ... on Key { versions(first: 2, after: $after) { edges { node { uuid } } pageInfo { hasNextPage } } } } }`,
+ variables: { after: firstPage.pageInfo.endCursor },
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(next.errors, undefined, JSON.stringify(next.errors));
+ assert.deepEqual(next.data.node.versions, {
+ edges: [{ node: { uuid: stored[2]!.id } }],
+ pageInfo: { hasNextPage: false },
+ });
+ const anonymous = await (
+ await query({
+ query: `{ node(id: "${Buffer.from(`ActivityLog:${node.uuid}`).toString("base64")}") { ... on ActivityLog { ${fields} } } }`,
+ })
+ ).json();
+ assert.ok(anonymous.errors?.length);
+ assert.equal(anonymous.data?.node ?? null, null);
+ });
+});
diff --git a/packages/graphql/src/activity-log/inbound.ts b/packages/graphql/src/activity-log/inbound.ts
new file mode 100644
index 0000000..d7e6660
--- /dev/null
+++ b/packages/graphql/src/activity-log/inbound.ts
@@ -0,0 +1,300 @@
+// 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 { recordInbound } from "@drfed/models/activity-log";
+import type {
+ ActivityLogVerificationResult,
+ Instance,
+} from "@drfed/models/schema";
+import { type Uuid, uuidV7, validateUuid } from "@drfed/models/uuid";
+import type { FederationFetchOptions } from "@fedify/fedify";
+import type { DocumentLoader } from "@fedify/vocab";
+import { getLogger } from "@logtape/logtape";
+
+import { canonicalizeAuthority, instanceUrl } from "../origin.ts";
+import { addressedIris, findAddressedActors } from "./addressing.ts";
+import { describeParsed, parseActivity, remoteHost } from "./describe.ts";
+import { describeError, receivedMeanwhile } from "./queue.ts";
+import {
+ type Report,
+ type TrackedFederation,
+ kvOf,
+ trackSettled,
+ unwrap,
+} from "./tracking.ts";
+import { observeVerification } from "./verification.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+type InboundStatus = "received" | "acknowledged" | "unverified" | "rejected";
+
+/**
+ * The actual federation response determines whether an activity was accepted.
+ * @param statusCode Null when handling threw instead of answering.
+ * @returns The inbound delivery status.
+ */
+export function classifyInbound({
+ statusCode,
+ handled,
+ verificationResult,
+}: {
+ readonly statusCode: number | null;
+ readonly handled: boolean;
+ readonly verificationResult: ActivityLogVerificationResult;
+}): InboundStatus {
+ if (statusCode != null && statusCode >= 200 && statusCode < 300) {
+ return handled ? "received" : "acknowledged";
+ }
+ return verificationResult === "verified" ? "rejected" : "unverified";
+}
+
+/**
+ * Drop `cookie`, and reduce non-`Signature` `authorization` to its scheme.
+ * @returns The headers as `[name, value]` pairs.
+ */
+export function recordedHeaders(headers: Headers): [string, string][] {
+ return [...headers]
+ .filter(([name]) => name !== "cookie")
+ .map(([name, value]): [string, string] => {
+ if (name !== "authorization") return [name, value];
+ const [scheme = ""] = value.trim().split(/\s+/u);
+ return [name, scheme.toLowerCase() === "signature" ? value : scheme];
+ });
+}
+
+/**
+ * Parse a body from its octets.
+ * @returns The parsed value, or undefined when the body is not JSON.
+ */
+export function parseBody(body: Uint8Array): unknown {
+ try {
+ const value: unknown = JSON.parse(new TextDecoder().decode(body));
+ return value;
+ } catch {
+ return undefined;
+ }
+}
+
+async function findRecordingInstance(db: Database, host: string) {
+ try {
+ return await db.query.instances.findFirst({
+ where: {
+ host: canonicalizeAuthority(host),
+ localId: { isNotNull: true },
+ },
+ });
+ } catch (error) {
+ logger.error("Could not resolve the inbox recording instance: {error}", {
+ error,
+ });
+ return undefined;
+ }
+}
+
+async function readBody(request: Request): Promise {
+ try {
+ return new Uint8Array(await request.clone().arrayBuffer());
+ } catch (error) {
+ logger.error("Could not read the inbox request body: {error}", { error });
+ return undefined;
+ }
+}
+
+async function readResponseBody(response: Response): Promise {
+ try {
+ return await response.clone().text();
+ } catch (error) {
+ logger.error("Could not read the inbox response body: {error}", { error });
+ return null;
+ }
+}
+
+/**
+ * Wrap inbox POSTs while preserving Fedify responses even when recording fails.
+ * A request Fedify throws on is recorded too, and the exception thrown again.
+ * The federation must come from `createFederation()`, which lets the recorder
+ * see how Fedify verified each request and with which public key.
+ * @returns A fetch handler that records inbox observations.
+ */
+export function createInboundRecorder({
+ db,
+ federation,
+ rootOrigin,
+}: {
+ readonly db: Database;
+ readonly federation: TrackedFederation;
+ readonly rootOrigin: URL;
+}): {
+ fetch(
+ request: Request,
+ options: FederationFetchOptions,
+ ): Promise;
+} {
+ async function record(
+ request: Request,
+ instance: Instance,
+ identifier: string | undefined,
+ observed: {
+ readonly id: Uuid;
+ readonly body: Uint8Array;
+ readonly payload: unknown;
+ readonly report: Pick;
+ readonly outcome: PromiseSettledResult;
+ readonly handled: boolean;
+ readonly loaders: Parameters[1] & {
+ readonly contextLoader: DocumentLoader;
+ };
+ readonly created: Temporal.Instant;
+ readonly completed: Temporal.Instant;
+ },
+ ): Promise {
+ const {
+ id,
+ body,
+ payload,
+ report,
+ outcome,
+ handled,
+ loaders,
+ created,
+ completed,
+ } = observed;
+ const response = outcome.status === "fulfilled" ? outcome.value : null;
+ const statusCode = response?.status ?? null;
+ const verification = await observeVerification(
+ db,
+ request.headers,
+ payload,
+ report,
+ loaders.contextLoader,
+ );
+ const owner =
+ identifier != null && validateUuid(identifier)
+ ? await db.query.actors.findFirst({
+ where: {
+ id: identifier,
+ instanceId: instance.id,
+ localId: { isNotNull: true },
+ },
+ })
+ : null;
+ const activity =
+ payload === undefined ? null : await parseActivity(payload, loaders);
+ const description = await describeParsed(payload, activity, loaders);
+ const status = classifyInbound({
+ statusCode,
+ handled,
+ verificationResult: verification.result,
+ });
+ const responseBody =
+ response == null ? null : await readResponseBody(response);
+ await recordInbound(db, {
+ ...description,
+ id,
+ instanceId: instance.id,
+ actorId: owner?.id ?? null,
+ addressed: await findAddressedActors(
+ db,
+ instance.id,
+ addressedIris(activity),
+ ),
+ status,
+ verificationMechanism: verification.mechanism,
+ verificationResult: verification.result,
+ signedKeyIri: verification.signedKeyIri,
+ verificationKeyId: verification.keyId,
+ remoteHost: remoteHost(
+ description.remoteActorIri,
+ verification.signedKeyIri,
+ ),
+ inboxUrl:
+ owner?.inboxUrl ??
+ instanceUrl(rootOrigin, instance.host, new URL(request.url).pathname)
+ .href,
+ requestUrl: request.url,
+ headers: recordedHeaders(request.headers),
+ body,
+ statusCode,
+ responseBody,
+ error:
+ outcome.status === "rejected"
+ ? describeError(outcome.reason)
+ : status === "unverified" || status === "rejected"
+ ? (verification.detail ?? (responseBody || null))
+ : null,
+ payload,
+ created,
+ completed,
+ });
+ // A queue worker may have run the inbox listener before this was recorded.
+ const kv = kvOf(federation);
+ if (status === "acknowledged" && kv != null) {
+ await receivedMeanwhile(db, kv, id);
+ }
+ }
+
+ return {
+ async fetch(request, options) {
+ // Arrival, not insertion, orders the logs: requests may finish out of order.
+ const created = Temporal.Now.instant();
+ if (request.method !== "POST") {
+ return await federation.fetch(request, options);
+ }
+ const ctx = federation.createContext(request, options.contextData);
+ const route = ctx.parseUri(new URL(request.url));
+ if (route?.type !== "inbox") {
+ return await federation.fetch(request, options);
+ }
+ // Unclaimed subdomains must not create orphaned public-key history.
+ const instance = await findRecordingInstance(db, ctx.host);
+ const body = instance == null ? undefined : await readBody(request);
+ if (instance == null || body == null) {
+ return await federation.fetch(request, options);
+ }
+ const loaders = {
+ documentLoader: ctx.documentLoader,
+ contextLoader: ctx.contextLoader,
+ };
+ const payload = parseBody(body);
+ // Fedify reports how it verified the request as it handles it; nothing
+ // is verified again, so the log shows Fedify's outcome and keys.
+ // Chosen now, so that a queued inbox message can name the log.
+ const id = uuidV7();
+ const { outcome, handled, ...report } = await trackSettled(
+ () => federation.fetch(request, options),
+ { inboundLogId: id },
+ );
+ const completed = Temporal.Now.instant();
+ try {
+ await record(request, instance, route.identifier, {
+ id,
+ body,
+ payload,
+ report,
+ outcome,
+ handled,
+ loaders,
+ created,
+ completed,
+ });
+ } catch (error) {
+ logger.error("Could not record inbox activity: {error}", { error });
+ }
+ return unwrap(outcome);
+ },
+ };
+}
diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts
new file mode 100644
index 0000000..69ac2c1
--- /dev/null
+++ b/packages/graphql/src/activity-log/keycache.ts
@@ -0,0 +1,150 @@
+// 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 {
+ FetchKeyErrorResult,
+ KeyCache,
+ KvKey,
+ KvStore,
+} from "@fedify/fedify";
+import { CryptographicKey, Multikey } from "@fedify/vocab";
+
+import type { ObservedKeyFetch } from "./tracking.ts";
+
+type Loaders = Parameters[1];
+type DiagnosticKeyCache = KeyCache & {
+ getFetchError(keyId: URL): Promise;
+ setFetchError(
+ keyId: URL,
+ error: FetchKeyErrorResult | undefined,
+ ): Promise;
+};
+// Fedify's KvKeyCache keeps each key below the prefix under this generation.
+const generation = "2";
+const keyTtl = Temporal.Duration.from({ days: 30 });
+const unavailableTtl = Temporal.Duration.from({ minutes: 10 });
+
+async function parseKey(
+ value: unknown,
+ options: Loaders,
+): Promise {
+ try {
+ return await CryptographicKey.fromJsonLd(value, options);
+ } catch {
+ try {
+ return await Multikey.fromJsonLd(value, options);
+ } catch {
+ return undefined;
+ }
+ }
+}
+
+/**
+ * KV wire format shared with the installed Fedify KvKeyCache.
+ * @returns A compatible public-key cache with fetch-error diagnostics.
+ */
+export function createKeyCache(
+ kv: KvStore,
+ prefix: KvKey = ["_fedify", "publicKey"],
+ options: Loaders = {},
+): DiagnosticKeyCache {
+ const entryKey = (id: URL): KvKey => [...prefix, generation, id.href];
+ const errorKey = (id: URL): KvKey => [...prefix, "__fetchError", id.href];
+ return {
+ async get(keyId) {
+ const value = await kv.get(entryKey(keyId));
+ if (value == null) return value;
+ const key = await parseKey(value, options);
+ if (key == null) await kv.delete(entryKey(keyId));
+ return key;
+ },
+ async set(keyId, key) {
+ await kv.set(
+ entryKey(keyId),
+ key == null ? null : await key.toJsonLd(options),
+ { ttl: key == null ? unavailableTtl : keyTtl },
+ );
+ },
+ async getFetchError(keyId) {
+ const cached = await kv.get>(errorKey(keyId));
+ if (cached == null || typeof cached !== "object") return undefined;
+ if (
+ typeof cached.status === "number" &&
+ typeof cached.statusText === "string" &&
+ Array.isArray(cached.headers) &&
+ typeof cached.body === "string"
+ ) {
+ return {
+ status: cached.status,
+ response: new Response(cached.body, {
+ status: cached.status,
+ statusText: cached.statusText,
+ headers: cached.headers as [string, string][],
+ }),
+ };
+ }
+ if (
+ typeof cached.errorName === "string" &&
+ typeof cached.errorMessage === "string"
+ ) {
+ const error = new Error(cached.errorMessage);
+ error.name = cached.errorName;
+ return { error };
+ }
+ return undefined;
+ },
+ async setFetchError(keyId, result) {
+ if (result == null) return await kv.delete(errorKey(keyId));
+ const value =
+ "status" in result
+ ? {
+ status: result.status,
+ statusText: result.response.statusText,
+ headers: Array.from(result.response.headers.entries()),
+ body: await result.response.clone().text(),
+ }
+ : {
+ errorName: result.error.name,
+ errorMessage: result.error.message,
+ };
+ await kv.set(errorKey(keyId), value, { ttl: unavailableTtl });
+ },
+ };
+}
+
+/**
+ * The key a verification used, out of the key fetches `trackRequest()`
+ * reported of it: the last its public-key cache entry held while a fetch
+ * brought a key, even one fetched again, and even when fetching it again then
+ * failed and emptied the entry. What a fetch read but could not use does not
+ * count, nor what another verification of the request found under the IRI.
+ * @param fetches The key fetches of the one verification, in order.
+ * @returns The key, or null when no fetch brought one.
+ */
+export async function trackedKey(
+ fetches: readonly ObservedKeyFetch[],
+ keyIri: string,
+ options: Loaders = {},
+): Promise {
+ const entry = JSON.stringify([generation, keyIri]);
+ const brought = await Promise.all(
+ fetches
+ .filter(({ result }) => result === "hit" || result === "fetched")
+ .flatMap(({ keys }) => keys.get(entry) ?? [])
+ .map((value) => (value == null ? undefined : parseKey(value, options))),
+ );
+ return brought.findLast((key) => key != null) ?? null;
+}
diff --git a/packages/graphql/src/activity-log/outbound.test.ts b/packages/graphql/src/activity-log/outbound.test.ts
new file mode 100644
index 0000000..481c842
--- /dev/null
+++ b/packages/graphql/src/activity-log/outbound.test.ts
@@ -0,0 +1,325 @@
+// 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 .
+
+// oxlint-disable max-statements no-await-in-loop no-throw-literal
+import assert from "node:assert/strict";
+import { it } from "node:test";
+
+import { deliverActivity, groupRecipients } from "@drfed/graphql/activity-log";
+import createFederation from "@drfed/graphql/federation";
+import type { Database } from "@drfed/models";
+import {
+ type Context,
+ InProcessMessageQueue,
+ MemoryKvStore,
+ SendActivityError,
+ type SendActivityOptions,
+ generateCryptoKeyPair,
+} from "@fedify/fedify";
+import {
+ type Activity,
+ Create,
+ PUBLIC_COLLECTION,
+ type Recipient,
+} from "@fedify/vocab";
+
+import { withTemporaryDatabase } from "../harness.test.ts";
+import { localActorId, seedLocalActor } from "../seed.test.ts";
+import { withInbox } from "./remote.test.ts";
+
+const origin = "https://test-instance.drfed.org";
+const localActorIri = new URL(`${origin}/users/${localActorId}`);
+const inboxId = new URL("https://remote.example/inbox");
+const alice = { id: new URL("https://remote.example/users/alice"), inboxId };
+const bob = { id: new URL("https://remote.example/users/bob"), inboxId };
+const sender = { identifier: localActorId };
+const create = (
+ id: string,
+ extra: ConstructorParameters[0] = {},
+) =>
+ new Create({
+ id: new URL(`${origin}/activity/${id}`),
+ actor: localActorIri,
+ ...extra,
+ });
+
+/**
+ * A context whose `sendActivity()` is `send`, for a delivery whose outcome the
+ * test decides.
+ * @returns The context.
+ */
+async function createContext(
+ db: Database,
+ send: (activity: Activity, recipients: readonly Recipient[]) => Promise,
+): Promise> {
+ const federation = await createFederation(db, { kv: new MemoryKvStore() });
+ return new Proxy(federation.createContext(new URL(origin), undefined), {
+ get(target, property) {
+ return property === "sendActivity"
+ ? (_sender: unknown, recipients: Recipient[], activity: Activity) =>
+ send(activity, recipients)
+ : Reflect.get(target, property);
+ },
+ });
+}
+
+/**
+ * A context delivering through Fedify with a key of the test's own, since
+ * local key pairs arrive with #87. With a queue, its worker is never started.
+ * @returns The context, and the activities passed to Fedify.
+ */
+async function createDeliveringContext(
+ db: Database,
+ {
+ queue,
+ ...options
+ }: SendActivityOptions & { readonly queue?: InProcessMessageQueue } = {},
+): Promise<{ readonly ctx: Context; readonly passed: Activity[] }> {
+ const federation = await createFederation(db, {
+ kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
+ ...(queue == null ? {} : { queue, manuallyStartQueue: true }),
+ });
+ const { privateKey } = await generateCryptoKeyPair("Ed25519");
+ const key = { keyId: new URL(`${localActorIri.href}#key`), privateKey };
+ const passed: Activity[] = [];
+ const ctx = new Proxy(federation.createContext(new URL(origin), undefined), {
+ get(target, property) {
+ return property === "sendActivity"
+ ? (_sender: unknown, recipients: Recipient[], activity: Activity) => {
+ passed.push(activity);
+ return target.sendActivity(key, recipients, activity, options);
+ }
+ : Reflect.get(target, property);
+ },
+ });
+ return { ctx, passed };
+}
+
+const logs = (db: Database) =>
+ db.query.activityLogs.findMany({ orderBy: { id: "asc" } });
+
+it("keeps the outcome of an earlier attempt when a retry to the same inbox fails", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const activity = create("retried");
+ for (const [statusCode, message, body] of [
+ [503, "unavailable", "First"],
+ [502, "bad gateway", "Second"],
+ ] as const) {
+ const ctx = await createContext(db, () =>
+ Promise.reject(
+ new SendActivityError(inboxId, statusCode, message, body),
+ ),
+ );
+ await assert.rejects(
+ deliverActivity(db, ctx, sender, alice, activity),
+ SendActivityError,
+ );
+ }
+ assert.deepEqual(
+ (await logs(db)).map((log) => [
+ log.status,
+ log.statusCode,
+ log.error,
+ log.responseBody,
+ ]),
+ [
+ ["failed", 503, "unavailable", "First"],
+ ["failed", 502, "bad gateway", "Second"],
+ ],
+ );
+ });
+});
+
+it("settles a delivery whose remote response holds text PostgreSQL cannot store", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const body = "error\u0000details";
+ const ctx = await createContext(db, () =>
+ Promise.reject(
+ new SendActivityError(inboxId, 500, `Failed:\n${body}`, body),
+ ),
+ );
+ await assert.rejects(
+ deliverActivity(db, ctx, sender, alice, create("nul")),
+ SendActivityError,
+ );
+ const [log] = await db.query.activityLogs.findMany({
+ with: { attempts: true },
+ });
+ assert.deepEqual(
+ [log?.status, log?.statusCode, log?.error, log?.responseBody],
+ ["failed", 500, "Failed:\nerror\ufffddetails", "error\ufffddetails"],
+ );
+ assert.deepEqual(
+ log?.attempts.map((attempt) => [
+ attempt.succeeded,
+ attempt.statusCode,
+ attempt.responseBody,
+ ]),
+ [[false, 500, "error\ufffddetails"]],
+ );
+ });
+});
+
+it("logs every recipient of a shared inbox and strips blind recipients before delivery", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { ctx, passed } = await createDeliveringContext(db);
+ const activity = create("blind", {
+ tos: [alice.id],
+ btos: [new URL("urn:bto")],
+ bccs: [bob.id],
+ });
+ const [log, ...rest] = await withInbox(
+ [[202, ""]],
+ async (inbox, received) => {
+ const shared = { id: alice.id, inboxId: inbox };
+ const other = { id: bob.id, inboxId: inbox };
+ await deliverActivity(
+ db,
+ ctx,
+ sender,
+ [shared, other, shared],
+ activity,
+ );
+ assert.equal(received.length, 1);
+ const sent = JSON.parse(received[0]?.body ?? "") as Record<
+ string,
+ unknown
+ >;
+ assert.equal(sent.to, alice.id.href);
+ assert.equal("bto" in sent, false);
+ assert.equal("bcc" in sent, false);
+ return await logs(db);
+ },
+ );
+ assert.deepEqual(rest, []);
+ assert.equal(log?.status, "sent");
+ assert.equal(log?.statusCode, 202);
+ assert.deepEqual(log?.recipientIris, [alice.id.href, bob.id.href]);
+ assert.equal(log?.remoteActorIri, null);
+ assert.match(log?.remoteHost ?? "", /^127\.0\.0\.1:\d+$/u);
+ const payload = log?.payload as Record;
+ assert.equal(payload.to, alice.id.href);
+ assert.equal("bto" in payload, false);
+ assert.equal("bcc" in payload, false);
+ assert.equal(passed.length, 1);
+ assert.deepEqual(passed[0]?.btoIds, []);
+ assert.deepEqual(passed[0]?.bccIds, []);
+ assert.deepEqual(passed[0]?.toIds, [alice.id]);
+ assert.deepEqual(activity.bccIds, [bob.id]);
+ assert.deepEqual(await db.query.activityLogActors.findMany(), [
+ {
+ logId: log?.id,
+ actorId: localActorId,
+ inboxOwner: false,
+ addressed: false,
+ sender: true,
+ viaCollectionIri: null,
+ },
+ ]);
+ });
+ assert.deepEqual(
+ [
+ ...groupRecipients(
+ [
+ alice,
+ { id: localActorIri, inboxId: new URL(`${origin}/inbox`) },
+ { id: PUBLIC_COLLECTION, inboxId },
+ { id: bob.id, inboxId: null },
+ { id: null, inboxId },
+ ],
+ localActorIri.href,
+ ),
+ ],
+ [[inboxId.href, [alice]]],
+ );
+});
+
+it("logs no delivery to a recipient Fedify leaves out for having no ID", async () => {
+ for (const queue of [undefined, new InProcessMessageQueue()]) {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { ctx } = await createDeliveringContext(db, {
+ ...(queue == null ? {} : { queue }),
+ });
+ await withInbox([[202, ""]], async (inbox, received) => {
+ const anonymous = { id: null, inboxId: inbox };
+ const named = { id: alice.id, inboxId: inbox };
+ await deliverActivity(db, ctx, sender, anonymous, create("anonymous"));
+ assert.deepEqual(received, []);
+ assert.deepEqual(await logs(db), []);
+ // Sharing an inbox with a recipient Fedify delivers to changes nothing.
+ await deliverActivity(
+ db,
+ ctx,
+ sender,
+ [anonymous, named],
+ create("mixed"),
+ );
+ const [log, ...rest] = await logs(db);
+ assert.deepEqual(rest, []);
+ assert.deepEqual(log?.recipientIris, [alice.id.href]);
+ assert.equal(log?.remoteActorIri, alice.id.href);
+ assert.equal(log?.status, queue == null ? "sent" : "queued");
+ assert.equal(received.length, queue == null ? 1 : 0);
+ });
+ });
+ }
+});
+
+it("settles a delivery Fedify returns from without making", async () => {
+ // Fedify leaves out every recipient at an excluded origin, and returns as if
+ // it had delivered.
+ for (const queue of [undefined, new InProcessMessageQueue()]) {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ await withInbox([[202, ""]], async (inbox, received) => {
+ const { ctx } = await createDeliveringContext(db, {
+ ...(queue == null ? {} : { queue }),
+ excludeBaseUris: [inbox],
+ });
+ const recipient = { id: alice.id, inboxId: inbox };
+ await deliverActivity(db, ctx, sender, recipient, create("excluded"));
+ assert.deepEqual(received, []);
+ const [log] = await db.query.activityLogs.findMany({
+ with: { attempts: true },
+ });
+ assert.equal(log?.status, "permanently_failed");
+ assert.equal(log?.statusCode, null);
+ assert.equal(log?.error, "Fedify made no delivery to the inbox.");
+ assert.ok(log?.completed != null);
+ assert.deepEqual(log?.attempts, []);
+ });
+ });
+ }
+});
+
+it("leaves a delivery queued until the outbox worker attempts it", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ const { ctx } = await createDeliveringContext(db, {
+ queue: new InProcessMessageQueue(),
+ });
+ await deliverActivity(db, ctx, sender, alice, create("queued"));
+ const [log] = await logs(db);
+ assert.equal(log?.status, "queued");
+ assert.equal(log?.completed, null);
+ assert.equal(log?.remoteActorIri, alice.id.href);
+ });
+});
diff --git a/packages/graphql/src/activity-log/outbound.ts b/packages/graphql/src/activity-log/outbound.ts
new file mode 100644
index 0000000..6c62419
--- /dev/null
+++ b/packages/graphql/src/activity-log/outbound.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 .
+
+import type { Database } from "@drfed/models";
+import { recordOutbound, settleOutbound } from "@drfed/models/activity-log";
+import type { ActivityLog } from "@drfed/models/schema";
+import type { Uuid } from "@drfed/models/uuid";
+import type { Context, Federation } from "@fedify/fedify";
+import {
+ type Activity,
+ PUBLIC_COLLECTION,
+ type Recipient,
+} from "@fedify/vocab";
+import { getLogger } from "@logtape/logtape";
+
+import { canonicalizeAuthority } from "../origin.ts";
+import { describeActivity, remoteHost } from "./describe.ts";
+import {
+ type Delivery,
+ type Settlement,
+ deliveredStatus,
+ failureOf,
+ reportsSent,
+ withDelivery,
+} from "./queue.ts";
+import { trackRequest } from "./tracking.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+const queued = new WeakSet>();
+
+/**
+ * How a delivery Fedify returned from without sending or enqueuing settles:
+ * no attempt was made, and none will be.
+ */
+const undelivered: Settlement = {
+ status: "permanently_failed",
+ attempted: false,
+ statusCode: null,
+ error: "Fedify made no delivery to the inbox.",
+ responseBody: null,
+};
+
+/** Mark a federation as delivering through a message queue. */
+export function markQueued(federation: Federation): void {
+ queued.add(federation);
+}
+
+/**
+ * Group recipients by inbox, without the sender and Public. Fedify delivers
+ * only to a recipient that has both an ID and an inbox, so any other is left
+ * out.
+ * @returns The recipients of each inbox URL.
+ */
+export function groupRecipients(
+ recipients: readonly Recipient[],
+ senderIri: string | undefined,
+): Map {
+ const targets = new Map();
+ for (const recipient of recipients) {
+ const iri = recipient.id?.href;
+ if (
+ iri == null ||
+ recipient.inboxId == null ||
+ iri === senderIri ||
+ iri === PUBLIC_COLLECTION.href
+ ) {
+ continue;
+ }
+ const url = recipient.inboxId.href;
+ const group = targets.get(url) ?? [];
+ if (group.some((member) => member.id?.href === iri)) continue;
+ targets.set(url, [...group, recipient]);
+ }
+ return targets;
+}
+
+/**
+ * Deliver to explicit recipients through the current federation.
+ * Each activity needs a unique IRI. `bto` and `bcc` are removed before delivery.
+ * A recipient without an ID or an inbox gets no delivery, and so no log.
+ * With a message queue, `createFederation()` settles each attempt the worker
+ * makes; without one, the one attempt settles here.
+ * Local actor key dispatchers must be registered before using this entry point.
+ */
+export async function deliverActivity(
+ db: Database,
+ ctx: Context,
+ sender: { identifier: Uuid },
+ recipients: Recipient | Recipient[],
+ activity: Activity,
+): Promise {
+ if (activity.id == null) {
+ throw new TypeError("A delivered activity must have an ID.");
+ }
+ const actor = await db.query.actors.findFirst({
+ where: {
+ id: sender.identifier,
+ localId: { isNotNull: true },
+ instance: {
+ host: canonicalizeAuthority(ctx.host),
+ localId: { isNotNull: true },
+ },
+ },
+ });
+ if (actor == null) {
+ throw new TypeError("Delivery requires a local sender on this instance.");
+ }
+ const activityIri = activity.id.href;
+ const delivered = activity.clone({ btos: [], bccs: [] });
+ const synchronous = !queued.has(ctx.federation);
+ const targets = groupRecipients(
+ Array.isArray(recipients) ? recipients : [recipients],
+ activity.actorId?.href,
+ );
+ const results = await Promise.allSettled(
+ Array.from(targets, async ([inboxUrl, group]) => {
+ let row: ActivityLog | undefined;
+ try {
+ const payload = await delivered.toJsonLd({
+ format: "compact",
+ contextLoader: ctx.contextLoader,
+ });
+ const description = await describeActivity(payload, {
+ contextLoader: ctx.contextLoader,
+ });
+ const recipientIris = group.flatMap((recipient) =>
+ recipient.id == null ? [] : [recipient.id.href],
+ );
+ const remoteActorIri =
+ group.length === 1 ? (recipientIris[0] ?? null) : null;
+ row = await recordOutbound(db, {
+ ...description,
+ instanceId: actor.instanceId,
+ actorId: actor.id,
+ activityIri,
+ remoteActorIri,
+ remoteHost: remoteHost(remoteActorIri, inboxUrl),
+ inboxUrl,
+ recipientIris,
+ payload,
+ });
+ } catch (error) {
+ logger.error("Could not record outgoing activity: {error}", { error });
+ }
+ const settle = async (settlement: Settlement) => {
+ if (row == null) return;
+ try {
+ await settleOutbound(db, {
+ ...settlement,
+ id: row.id,
+ activityIri,
+ inboxUrl,
+ });
+ } catch (error) {
+ logger.error("Could not settle outgoing activity: {error}", {
+ error,
+ });
+ }
+ };
+ // Resolve each destination independently: one failing inbox must not mark
+ // a successful delivery as failed or leave it queued.
+ const send = () => ctx.sendActivity(sender, group, delivered);
+ const delivery: Delivery | undefined =
+ row == null ? undefined : { logId: row.id, inboxUrl, enqueued: false };
+ const { spans, responses } = await trackRequest(() =>
+ delivery == null ? send() : withDelivery(delivery, send),
+ ).catch(async (error: unknown) => {
+ await settle({
+ ...failureOf(error),
+ status: "failed",
+ attempted: true,
+ });
+ throw error;
+ });
+ // Fedify returns without sending to a recipient it leaves out: the sent
+ // event tells a delivery made from none, and the enqueued message a
+ // delivery pending from none. A group is always one inbox, so Fedify
+ // never fans it out through a queue.
+ if (synchronous) {
+ await settle(
+ reportsSent(spans, inboxUrl)
+ ? {
+ status: "sent",
+ attempted: true,
+ statusCode: deliveredStatus(responses, inboxUrl),
+ }
+ : undelivered,
+ );
+ } else if (delivery?.enqueued === false) {
+ await settle(undelivered);
+ }
+ }),
+ );
+ const failure = results.find((result) => result.status === "rejected");
+ if (failure?.status === "rejected") throw failure.reason;
+}
diff --git a/packages/graphql/src/activity-log/queue.test.ts b/packages/graphql/src/activity-log/queue.test.ts
new file mode 100644
index 0000000..ecb3a54
--- /dev/null
+++ b/packages/graphql/src/activity-log/queue.test.ts
@@ -0,0 +1,377 @@
+// 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 .
+
+// These tests run Fedify's own delivery and outbox worker against an inbox
+// served on a local port.
+// oxlint-disable max-statements no-await-in-loop
+import assert from "node:assert/strict";
+import { it } from "node:test";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import {
+ deliverActivity,
+ queuedSettlements,
+} from "@drfed/graphql/activity-log";
+import createFederation from "@drfed/graphql/federation";
+import type { Database } from "@drfed/models";
+import {
+ type Context,
+ InProcessMessageQueue,
+ MemoryKvStore,
+ type MessageQueue,
+ generateCryptoKeyPair,
+} from "@fedify/fedify";
+import { type Activity, Create, type Recipient } from "@fedify/vocab";
+
+import { withTemporaryDatabase } from "../harness.test.ts";
+import { localActorId, seedLocalActor } from "../seed.test.ts";
+import { withInbox } from "./remote.test.ts";
+
+const origin = "https://test-instance.drfed.org";
+const localActorIri = new URL(`${origin}/users/${localActorId}`);
+const alice = new URL("https://remote.example/users/alice");
+
+/**
+ * Run with a temporary database holding the local actor deliveries are from.
+ * @returns The result of the run.
+ */
+async function withSeededDatabase(
+ run: (db: Database) => Promise,
+): Promise {
+ return await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ return await run(db);
+ });
+}
+
+const logs = (db: Database, activityIri: string) =>
+ db.query.activityLogs.findMany({
+ where: { activityIri },
+ orderBy: { created: "asc", id: "asc" },
+ with: { attempts: { orderBy: { created: "asc", id: "asc" } } },
+ });
+type Log = Awaited>[number];
+
+const results = (log: Log | undefined) =>
+ log?.attempts.map((attempt) => [
+ attempt.succeeded,
+ attempt.statusCode,
+ attempt.responseBody,
+ ]);
+
+/**
+ * Deliver one activity through Fedify to `inbox`, with `queue` if any, until
+ * every log is settled for good. The database holds the local actor.
+ * @param activity What tells the activity from others delivered from the
+ * same database.
+ * @param retries How many times the worker may retry a failed attempt.
+ * @param deliveries How many times to deliver the activity before the worker
+ * starts.
+ * @param algorithm The sender's key. Fedify signs requests only with RSA.
+ * @returns The logs, oldest first.
+ */
+async function deliver(
+ db: Database,
+ inbox: URL,
+ {
+ activity: name = "1",
+ queue,
+ retries = 3,
+ deliveries = 1,
+ algorithm = "Ed25519",
+ }: {
+ readonly activity?: string;
+ readonly queue?: MessageQueue;
+ readonly retries?: number;
+ readonly deliveries?: number;
+ readonly algorithm?: "Ed25519" | "RSASSA-PKCS1-v1_5";
+ } = {},
+): Promise {
+ const federation = await createFederation(db, {
+ kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
+ ...(queue == null ? {} : { queue, manuallyStartQueue: true }),
+ circuitBreaker: false,
+ outboxRetryPolicy: ({ attempts }) =>
+ attempts < retries ? Temporal.Duration.from({ milliseconds: 1 }) : null,
+ });
+ // Local key pairs arrive with #87; sign with a key of the test's own.
+ const { privateKey } = await generateCryptoKeyPair(algorithm);
+ const key = { keyId: new URL(`${localActorIri.href}#key`), privateKey };
+ const ctx: Context = new Proxy(
+ federation.createContext(new URL(origin), undefined),
+ {
+ get(target, property) {
+ return property === "sendActivity"
+ ? (_sender: unknown, recipients: Recipient[], activity: Activity) =>
+ target.sendActivity(key, recipients, activity)
+ : Reflect.get(target, property);
+ },
+ },
+ );
+ const activity = new Create({
+ id: new URL(`${origin}/activity/${name}`),
+ actor: localActorIri,
+ });
+ const recipient = { id: alice, inboxId: inbox };
+ for (let count = 0; count < deliveries; count += 1) {
+ await deliverActivity(
+ db,
+ ctx,
+ { identifier: localActorId },
+ recipient,
+ activity,
+ )
+ // A synchronous failure is thrown as well as logged.
+ .catch(() => undefined);
+ }
+ const activityIri = activity.id?.href ?? "";
+ if (queue == null) return await logs(db, activityIri);
+ const controller = new AbortController();
+ const worker = federation.startQueue(undefined, {
+ signal: controller.signal,
+ });
+ try {
+ for (let tries = 0; tries < 500; tries += 1) {
+ const found = await logs(db, activityIri);
+ const settled = found.every((log) =>
+ ["sent", "permanently_failed", "abandoned"].includes(log.status),
+ );
+ if (found.length === deliveries && settled) return found;
+ await sleep(10);
+ }
+ return assert.fail("The deliveries never settled.");
+ } finally {
+ controller.abort();
+ await worker;
+ }
+}
+
+it("keeps the status a remote inbox accepted a delivery with", async () => {
+ await withSeededDatabase(async (db) => {
+ const [log] = await withInbox([[202, ""]], (inbox) => deliver(db, inbox));
+ assert.equal(log?.status, "sent");
+ assert.equal(log?.statusCode, 202);
+ assert.deepEqual(results(log), [[true, 202, null]]);
+ });
+});
+
+it("keeps the status a redirected delivery ended with", async () => {
+ // Fedify follows a redirect itself when it signs the request, and leaves it
+ // to `fetch()` otherwise, which follows a 303 with a GET.
+ await withSeededDatabase(async (db) => {
+ for (const algorithm of ["Ed25519", "RSASSA-PKCS1-v1_5"] as const) {
+ for (const redirect of [303, 307]) {
+ for (const queued of [false, true]) {
+ const [log] = await withInbox(
+ [
+ [redirect, "", "/moved"],
+ [202, ""],
+ ],
+ (inbox) =>
+ deliver(db, inbox, {
+ activity: `${algorithm}-${redirect}-${queued}`,
+ algorithm,
+ ...(queued ? { queue: new InProcessMessageQueue() } : {}),
+ }),
+ );
+ assert.equal(log?.status, "sent");
+ assert.equal(log?.statusCode, 202);
+ assert.deepEqual(results(log), [[true, 202, null]]);
+ }
+ }
+ }
+ });
+});
+
+it("follows a redirect to a location outside ASCII as Fedify reads it", async () => {
+ // The header carries the UTF-8 bytes of the path, which Node.js writes from
+ // the Latin-1 string of them. Fedify follows the redirect itself whether it
+ // signs the request or not, reading them back as Latin-1.
+ const location = Buffer.from("/caf\u00e9", "utf8").toString("latin1");
+ await withSeededDatabase(async (db) => {
+ for (const algorithm of ["Ed25519", "RSASSA-PKCS1-v1_5"] as const) {
+ const { log, paths } = await withInbox(
+ [
+ [307, "", location],
+ [202, ""],
+ ],
+ async (inbox, received) => {
+ const [delivered] = await deliver(db, inbox, {
+ activity: algorithm,
+ algorithm,
+ });
+ return {
+ log: delivered,
+ paths: received.map((request) => request.url),
+ };
+ },
+ );
+ assert.equal(log?.status, "sent");
+ assert.equal(log?.statusCode, 202);
+ assert.deepEqual(paths, ["/inbox", "/caf%C3%83%C2%A9"]);
+ }
+ });
+});
+
+it("settles a queued delivery the remote inbox accepts", async () => {
+ await withSeededDatabase(async (db) => {
+ const [log] = await withInbox([[202, ""]], (inbox) =>
+ deliver(db, inbox, { queue: new InProcessMessageQueue() }),
+ );
+ assert.equal(log?.status, "sent");
+ assert.deepEqual(results(log), [[true, 202, null]]);
+ assert.ok(log?.completed != null);
+ });
+});
+
+it("keeps every failed attempt of a queued delivery that is retried into success", async () => {
+ await withSeededDatabase(async (db) => {
+ const [log] = await withInbox(
+ [
+ [503, "busy"],
+ [202, ""],
+ ],
+ (inbox) => deliver(db, inbox, { queue: new InProcessMessageQueue() }),
+ );
+ assert.deepEqual(results(log), [
+ [false, 503, "busy"],
+ [true, 202, null],
+ ]);
+ assert.equal(log?.error, null);
+ });
+});
+
+it("records network failures, and abandons a delivery once retries run out", async () => {
+ await withSeededDatabase(async (db) => {
+ const [log] = await withInbox(null, (inbox) =>
+ deliver(db, inbox, { queue: new InProcessMessageQueue(), retries: 1 }),
+ );
+ assert.deepEqual(results(log), [
+ [false, null, null],
+ [false, null, null],
+ ]);
+ for (const attempt of log?.attempts ?? []) {
+ assert.match(attempt.error ?? "", /ECONNREFUSED/u);
+ }
+ });
+});
+
+it("retries by Fedify's policy even with a queue that retries natively", async () => {
+ await withSeededDatabase(async (db) => {
+ const queue = new InProcessMessageQueue();
+ Object.defineProperty(queue, "nativeRetrial", { value: true });
+ const [log] = await withInbox(
+ [
+ [503, "busy"],
+ [503, "busy"],
+ ],
+ (inbox) => deliver(db, inbox, { queue, retries: 1 }),
+ );
+ assert.deepEqual(results(log), [
+ [false, 503, "busy"],
+ [false, 503, "busy"],
+ ]);
+ });
+});
+
+it("settles a permanent failure without retrying it", async () => {
+ await withSeededDatabase(async (db) => {
+ const [log] = await withInbox([[410, "gone"]], (inbox) =>
+ deliver(db, inbox, { queue: new InProcessMessageQueue() }),
+ );
+ assert.equal(log?.status, "permanently_failed");
+ assert.deepEqual(results(log), [[false, 410, "gone"]]);
+ assert.equal(log?.statusCode, 410);
+ });
+});
+
+it("settles each delivery of an activity sent twice to the same inbox", async () => {
+ await withSeededDatabase(async (db) => {
+ // The worker takes messages in order, so both deliveries are pending when
+ // their retries arrive: the first retry is refused for good.
+ const [first, second] = await withInbox(
+ [
+ [503, "busy"],
+ [503, "busy"],
+ [410, "gone"],
+ [202, ""],
+ ],
+ (inbox) =>
+ deliver(db, inbox, {
+ queue: new InProcessMessageQueue(),
+ retries: 1,
+ deliveries: 2,
+ }),
+ );
+ assert.equal(first?.status, "permanently_failed");
+ assert.deepEqual(results(first), [
+ [false, 503, "busy"],
+ [false, 410, "gone"],
+ ]);
+ assert.equal(second?.status, "sent");
+ assert.deepEqual(results(second), [
+ [false, 503, "busy"],
+ [true, 202, null],
+ ]);
+ });
+});
+
+it("translates what the worker reported into settlements", () => {
+ const attempt = {
+ activityIri: `${origin}/activity/1`,
+ inboxUrl: "https://remote.example/inbox",
+ };
+ const failure = { statusCode: 503, error: "busy", responseBody: null };
+ const expired = {
+ statusCode: null,
+ error: "Circuit breaker held activity expired.",
+ responseBody: null,
+ };
+ assert.deepEqual(
+ queuedSettlements({ ...attempt, sent: true, statusCode: 202 }),
+ [{ status: "sent", attempted: true, statusCode: 202 }],
+ );
+ // Held back by the circuit breaker before sending: nothing was attempted.
+ assert.deepEqual(queuedSettlements(attempt), []);
+ // Dropped by the circuit breaker before sending.
+ assert.deepEqual(queuedSettlements({ ...attempt, permanent: expired }), [
+ { ...expired, status: "permanently_failed", attempted: false },
+ ]);
+ // Failed and retried, or held back after failing.
+ for (const outcomes of [["retried"], []]) {
+ assert.deepEqual(queuedSettlements({ ...attempt, failure, outcomes }), [
+ { ...failure, status: "failed", attempted: true },
+ ]);
+ }
+ assert.deepEqual(
+ queuedSettlements({ ...attempt, failure, outcomes: ["abandoned"] }),
+ [{ ...failure, status: "abandoned", attempted: true }],
+ );
+ // Failed, then dropped because the circuit breaker held it too long.
+ assert.deepEqual(
+ queuedSettlements({
+ ...attempt,
+ failure,
+ permanent: expired,
+ outcomes: ["abandoned"],
+ }),
+ [
+ { ...failure, status: "failed", attempted: true },
+ { ...expired, status: "permanently_failed", attempted: false },
+ ],
+ );
+});
diff --git a/packages/graphql/src/activity-log/queue.ts b/packages/graphql/src/activity-log/queue.ts
new file mode 100644
index 0000000..9673740
--- /dev/null
+++ b/packages/graphql/src/activity-log/queue.ts
@@ -0,0 +1,509 @@
+// 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 { AsyncLocalStorage } from "node:async_hooks";
+
+import type { Database } from "@drfed/models";
+import {
+ type OutboundSettlement,
+ receiveInbound,
+ settleOutbound,
+} from "@drfed/models/activity-log";
+import { type Uuid, validateUuid } from "@drfed/models/uuid";
+import {
+ type FederationQueueOptions,
+ type KvKey,
+ type KvStore,
+ type MessageQueue,
+ type OutboxErrorHandler,
+ type OutboxPermanentFailureHandler,
+ SendActivityError,
+} from "@fedify/fedify";
+import { getLogger } from "@logtape/logtape";
+
+import {
+ type ObservedResponse,
+ type ObservedSpan,
+ trackRequest,
+ tracking,
+ untracked,
+} from "./tracking.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+/** How a delivery settles, apart from which delivery it is. */
+export type Settlement = Omit<
+ OutboundSettlement,
+ "activityIri" | "inboxUrl" | "id"
+>;
+
+/** What a failed attempt shows. */
+export interface Failure {
+ readonly statusCode: number | null;
+ readonly error: string;
+ readonly responseBody: string | null;
+}
+
+const MAX_CAUSES = 3;
+
+/**
+ * Network failures such as `fetch failed` carry DNS, TCP or TLS errors as
+ * causes.
+ * @returns The error with the causes it names.
+ */
+export function describeError(error: unknown, depth = 0): string {
+ return error instanceof Error && error.cause != null && depth < MAX_CAUSES
+ ? `${String(error)} (cause: ${describeError(error.cause, depth + 1)})`
+ : String(error);
+}
+
+/**
+ * Keep the remote response of an HTTP failure, and the causes of any other.
+ * @returns What the failed attempt shows.
+ */
+export function failureOf(error: unknown): Failure {
+ return error instanceof SendActivityError
+ ? {
+ statusCode:
+ error.statusCode >= 100 && error.statusCode <= 599
+ ? error.statusCode
+ : null,
+ error: error.message,
+ responseBody: error.responseBody,
+ }
+ : { statusCode: null, error: describeError(error), responseBody: null };
+}
+
+/** What Fedify's outbox worker reported while handling one message. */
+export interface QueuedAttempt {
+ readonly activityIri: string;
+ readonly inboxUrl: string;
+ /** The log the message belongs to, if it names one. */
+ readonly logId?: Uuid;
+ failure?: Failure;
+ permanent?: Failure;
+ /** Fedify reported `activitypub.activity.sent` for the inbox. */
+ sent?: boolean;
+ /** What the remote inbox answered the attempt. */
+ statusCode?: number | null;
+ /** The `activitypub.outbox.activity` results Fedify measured. */
+ outcomes?: readonly string[];
+}
+
+/** The log a queued message belongs to, carried with it through retries. */
+const LOG_ID = "drfedActivityLogId";
+
+interface OutboxMessage {
+ readonly type: "outbox";
+ readonly activityId?: string;
+ readonly inbox: string;
+ readonly [LOG_ID]?: unknown;
+}
+
+interface InboxMessage {
+ readonly type: "inbox";
+ readonly activity?: unknown;
+ readonly [LOG_ID]?: unknown;
+}
+
+const attempts = new AsyncLocalStorage();
+const receptions = new AsyncLocalStorage<{
+ readonly activityIri: string | undefined;
+ readonly logId: Uuid;
+}>();
+/** A delivery being run, which notes the outbox message enqueued for it. */
+export interface Delivery {
+ readonly logId: Uuid;
+ readonly inboxUrl: string;
+ /** Whether an outbox message to the inbox has been enqueued. */
+ enqueued: boolean;
+}
+
+const deliveries = new AsyncLocalStorage();
+
+const typed = (message: unknown, type: string): boolean =>
+ typeof message === "object" &&
+ message != null &&
+ (message as { type?: unknown }).type === type;
+const isOutbox = (message: unknown): message is OutboxMessage =>
+ typed(message, "outbox");
+const isInbox = (message: unknown): message is InboxMessage =>
+ typed(message, "inbox");
+const inboxActivityIri = (message: InboxMessage): string | undefined => {
+ const { activity } = message;
+ const id =
+ typeof activity === "object" && activity != null
+ ? (activity as { id?: unknown }).id
+ : undefined;
+ return typeof id === "string" ? id : undefined;
+};
+const loggedIn = (message: { readonly [LOG_ID]?: unknown }) => {
+ const logId = message[LOG_ID];
+ return typeof logId === "string" && validateUuid(logId) ? logId : undefined;
+};
+
+/**
+ * Run a delivery so that the outbox messages it enqueues name its log, which
+ * lets each attempt settle that log even if the activity is sent twice, and
+ * so that the delivery knows whether one was enqueued at all.
+ * @returns The result of the run.
+ */
+export function withDelivery(
+ delivery: Delivery,
+ run: () => Promise,
+): Promise {
+ return deliveries.run(delivery, run);
+}
+
+/**
+ * The log a message being enqueued belongs to: the delivery or the inbox
+ * request enqueuing it, or, for a retry, the message being handled.
+ * @returns The log ID, if any.
+ */
+function logOf(message: unknown): Uuid | undefined {
+ if (isOutbox(message)) {
+ const delivery = deliveries.getStore();
+ if (delivery != null && message.inbox === delivery.inboxUrl) {
+ delivery.enqueued = true;
+ return delivery.logId;
+ }
+ const attempt = attempts.getStore();
+ return attempt != null &&
+ message.activityId === attempt.activityIri &&
+ message.inbox === attempt.inboxUrl
+ ? attempt.logId
+ : undefined;
+ }
+ if (isInbox(message)) {
+ const reception = receptions.getStore();
+ if (reception != null) {
+ return inboxActivityIri(message) === reception.activityIri
+ ? reception.logId
+ : undefined;
+ }
+ return tracking()?.inboundLogId;
+ }
+ return undefined;
+}
+
+function tag(message: unknown): unknown {
+ const logId = logOf(message);
+ return logId == null ? message : { ...(message as object), [LOG_ID]: logId };
+}
+
+const RECEIVED_TTL = Temporal.Duration.from({ hours: 1 });
+const receivedKey = (logId: Uuid): KvKey => [
+ "drfed",
+ "activityLog",
+ "received",
+ logId,
+];
+
+async function receive(db: Database, kv: KvStore, logId: Uuid): Promise {
+ try {
+ // Mark first: the inbox request may not have recorded its log yet, and it
+ // looks for the mark once it has.
+ await kv.set(receivedKey(logId), true, { ttl: RECEIVED_TTL });
+ await receiveInbound(db, logId);
+ } catch (error) {
+ logger.error("Could not record a queued inbox reception: {error}", {
+ error,
+ });
+ }
+}
+
+/**
+ * Settle an inbound log recorded after the queue worker already ran its inbox
+ * listener, which left a mark for it.
+ */
+export async function receivedMeanwhile(
+ db: Database,
+ kv: KvStore,
+ logId: Uuid,
+): Promise {
+ if ((await kv.get(receivedKey(logId))) === true) {
+ await receiveInbound(db, logId);
+ }
+}
+
+/** Note an outbox failure on the attempt the worker is making. */
+export const reportOutboxError: OutboxErrorHandler = (error) => {
+ const attempt = attempts.getStore();
+ if (attempt != null) attempt.failure = failureOf(error);
+};
+
+/** Note that the worker gave up on the attempt it is making. */
+export const reportPermanentFailure: OutboxPermanentFailureHandler = (
+ _ctx,
+ values,
+) => {
+ const attempt = attempts.getStore();
+ if (attempt == null) return;
+ attempt.permanent =
+ values.reason === "http"
+ ? failureOf(values.error)
+ : { statusCode: null, error: values.error.message, responseBody: null };
+};
+
+/**
+ * Translate what the worker reported into settlements, in order. Success is
+ * what Fedify reports as sent, and giving up what it measures as `abandoned`.
+ * A message held back before sending made no attempt.
+ * @returns The settlements to apply to the delivery.
+ */
+export function queuedSettlements({
+ failure,
+ permanent,
+ sent = false,
+ statusCode = null,
+ outcomes = [],
+}: QueuedAttempt): Settlement[] {
+ if (sent) return [{ status: "sent", attempted: true, statusCode }];
+ const failed: Settlement[] =
+ failure == null
+ ? []
+ : [
+ {
+ ...failure,
+ status:
+ permanent == null && outcomes.includes("abandoned")
+ ? "abandoned"
+ : "failed",
+ attempted: true,
+ },
+ ];
+ return permanent == null
+ ? failed
+ : [
+ ...failed,
+ { ...permanent, status: "permanently_failed", attempted: false },
+ ];
+}
+
+/**
+ * Whether Fedify reported `activitypub.activity.sent` for the inbox, which is
+ * what makes a delivery sent: Fedify returns without sending to a recipient it
+ * leaves out.
+ * @returns Whether the activity was sent to the inbox.
+ */
+export const reportsSent = (
+ spans: readonly ObservedSpan[],
+ inboxUrl: string,
+): boolean =>
+ spans.some(
+ (span) =>
+ span.name === "activitypub.send_activity" &&
+ span.events.some(
+ (event) =>
+ event.name === "activitypub.activity.sent" &&
+ event.attributes["activitypub.inbox.url"] === inboxUrl,
+ ),
+ );
+
+/**
+ * Where a response sends the request it answered.
+ * @returns The URLs the next request may have, empty unless it redirects.
+ */
+const redirection = ({ status, locations }: ObservedResponse) =>
+ status >= 300 && status < 400 ? locations : [];
+
+/**
+ * The status a delivery ended with: that of the last response from the inbox,
+ * or from wherever the inbox redirected the delivery, however many times.
+ * @returns The status code, or null when no response came.
+ */
+export const deliveredStatus = (
+ responses: readonly ObservedResponse[],
+ inboxUrl: string,
+): number | null =>
+ responses.reduce<{
+ readonly urls: readonly string[];
+ readonly status: number | null;
+ }>(
+ (hop, response) => {
+ if (!hop.urls.includes(response.url)) return hop;
+ const next = redirection(response);
+ return {
+ urls: next.length > 0 ? next : hop.urls,
+ status: response.status,
+ };
+ },
+ { urls: [inboxUrl], status: null },
+ ).status;
+
+async function settleQueued(
+ db: Database,
+ attempt: QueuedAttempt,
+ logId: Uuid | undefined,
+): Promise {
+ try {
+ for (const settlement of queuedSettlements(attempt)) {
+ // oxlint-disable-next-line no-await-in-loop
+ await settleOutbound(db, {
+ ...settlement,
+ activityIri: attempt.activityIri,
+ inboxUrl: attempt.inboxUrl,
+ ...(logId == null ? {} : { id: logId }),
+ });
+ }
+ } catch (error) {
+ logger.error("Could not settle a queued delivery: {error}", { error });
+ }
+}
+
+async function handleInbox(
+ db: Database,
+ kv: KvStore,
+ message: InboxMessage,
+ logId: Uuid,
+ handler: (message: unknown) => Promise | void,
+): Promise {
+ const reception = { activityIri: inboxActivityIri(message), logId };
+ const { handled } = await receptions.run(reception, () =>
+ trackRequest(async () => await handler(message)),
+ );
+ if (handled) await receive(db, kv, logId);
+}
+
+async function handle(
+ db: Database,
+ kv: KvStore,
+ message: unknown,
+ handler: (message: unknown) => Promise | void,
+): Promise {
+ const logged = isInbox(message) ? loggedIn(message) : undefined;
+ if (isInbox(message) && logged != null) {
+ await handleInbox(db, kv, message, logged, handler);
+ return;
+ }
+ if (!isOutbox(message) || message.activityId == null) {
+ await handler(message);
+ return;
+ }
+ const id = loggedIn(message);
+ const attempt: QueuedAttempt = {
+ activityIri: message.activityId,
+ inboxUrl: message.inbox,
+ ...(id == null ? {} : { logId: id }),
+ };
+ try {
+ const { spans, outbox, responses } = await attempts.run(attempt, () =>
+ trackRequest(async () => await handler(message)),
+ );
+ attempt.sent = reportsSent(spans, message.inbox);
+ attempt.statusCode = deliveredStatus(responses, message.inbox);
+ attempt.outcomes = outbox;
+ } catch (error) {
+ attempt.failure ??= failureOf(error);
+ await settleQueued(db, attempt, id);
+ throw error;
+ }
+ await settleQueued(db, attempt, id);
+}
+
+function observeQueue(
+ db: Database,
+ kv: KvStore,
+ queue: MessageQueue,
+): MessageQueue {
+ return new Proxy(queue, {
+ get(target, property) {
+ // Fedify retries by its own policy, measuring each retry and giving up;
+ // a queue retrying by itself would give up without telling anyone.
+ if (property === "nativeRetrial") return false;
+ if (property === "enqueue") {
+ return async (
+ message: unknown,
+ options?: Parameters[1],
+ ) => {
+ await target.enqueue(tag(message), options);
+ };
+ }
+ if (property === "enqueueMany" && target.enqueueMany != null) {
+ const enqueueMany = target.enqueueMany.bind(target);
+ return async (
+ messages: readonly unknown[],
+ options?: Parameters[1],
+ ) => {
+ await enqueueMany(messages.map(tag), options);
+ };
+ }
+ if (property === "listen") {
+ // Workers outlive whatever request started them; keep them outside it.
+ return (
+ handler: (message: unknown) => Promise | void,
+ options?: Parameters[1],
+ ) =>
+ untracked(() =>
+ deliveries.exit(() =>
+ attempts.exit(() =>
+ target.listen(
+ (message) => handle(db, kv, message, handler),
+ options,
+ ),
+ ),
+ ),
+ );
+ }
+ const value: unknown = Reflect.get(target, property, target);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+}
+
+/**
+ * The outbox queue among Fedify's queue options.
+ * @returns The queue outgoing activities wait in, if any.
+ */
+export function outboxQueue(
+ queue: FederationQueueOptions | MessageQueue | undefined,
+): MessageQueue | undefined {
+ return queue == null || ("enqueue" in queue && "listen" in queue)
+ ? queue
+ : queue.outbox;
+}
+
+/**
+ * Observe what Fedify's queue workers do with each message: each delivery
+ * attempt settles its outbound log, and an inbox listener run settles the
+ * inbound log of the request that enqueued it. The queues report no native
+ * retrial, so that Fedify's own policy retries. Queues shared among roles
+ * stay shared.
+ * @returns Queue options to pass to Fedify instead.
+ */
+export function observeQueues(
+ db: Database,
+ kv: KvStore,
+ queue: FederationQueueOptions | MessageQueue,
+): FederationQueueOptions | MessageQueue {
+ if ("enqueue" in queue && "listen" in queue) {
+ return observeQueue(db, kv, queue);
+ }
+ const observed = new Map();
+ const wrap = (value: MessageQueue) => {
+ const known = observed.get(value);
+ if (known != null) return known;
+ const created = observeQueue(db, kv, value);
+ observed.set(value, created);
+ return created;
+ };
+ return Object.fromEntries(
+ Object.entries(queue).map(([role, value]) => [
+ role,
+ value == null ? value : wrap(value as MessageQueue),
+ ]),
+ ) as FederationQueueOptions;
+}
diff --git a/packages/graphql/src/activity-log/remote.test.ts b/packages/graphql/src/activity-log/remote.test.ts
new file mode 100644
index 0000000..8e95677
--- /dev/null
+++ b/packages/graphql/src/activity-log/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-log/telemetry.ts b/packages/graphql/src/activity-log/telemetry.ts
new file mode 100644
index 0000000..ef7be81
--- /dev/null
+++ b/packages/graphql/src/activity-log/telemetry.ts
@@ -0,0 +1,346 @@
+// 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 diagnostics from "node:diagnostics_channel";
+
+import {
+ type Attributes,
+ type Counter,
+ type Histogram,
+ type Meter,
+ type MeterProvider,
+ type Span,
+ SpanStatusCode,
+ type Tracer,
+ type TracerProvider,
+} from "@opentelemetry/api";
+
+import {
+ type ObservedKeyFetch,
+ type ObservedKeyLookup,
+ type ObservedSpan,
+ type Tracked,
+ tracking,
+} from "./tracking.ts";
+
+/** The spans whose attributes and events Fedify documents as its report. */
+const REPORTED_SPANS: ReadonlySet = new Set([
+ "activitypub.inbox",
+ "activitypub.send_activity",
+ "http_signatures.verify",
+ "ld_signatures.verify",
+ "object_integrity_proofs.verify",
+]);
+const SIGNATURE_METRIC = "activitypub.signature.verification.duration";
+const KEY_FETCH_METRIC = "activitypub.signature.key_fetch.duration";
+const KEY_LOOKUP_METRIC = "activitypub.key.lookup";
+const OUTBOX_METRIC = "activitypub.outbox.activity";
+
+const bindTo = (target: object, property: string | symbol): unknown => {
+ const value: unknown = Reflect.get(target, property, target);
+ return typeof value === "function" ? value.bind(target) : value;
+};
+
+function observeSpan(name: string, span: Span): Span {
+ const state = tracking();
+ if (state == null || !REPORTED_SPANS.has(name)) return span;
+ const attributes = new Map();
+ const events: ObservedSpan["events"][number][] = [];
+ const observed = { name, attributes, events, failed: false };
+ state.spans.push(observed);
+ const proxy: Span = new Proxy(span, {
+ get(target, property) {
+ switch (property) {
+ // Fedify records some attributes only on recording spans.
+ case "isRecording":
+ return () => true;
+ case "setAttribute":
+ return (key: string, value: Parameters[1]) => {
+ attributes.set(key, value);
+ target.setAttribute(key, value);
+ return proxy;
+ };
+ case "setAttributes":
+ return (values: Attributes) => {
+ for (const [key, value] of Object.entries(values)) {
+ attributes.set(key, value);
+ }
+ target.setAttributes(values);
+ return proxy;
+ };
+ case "addEvent":
+ return (event: string, ...rest: unknown[]) => {
+ const [values] = rest;
+ events.push({
+ name: event,
+ attributes:
+ values != null &&
+ typeof values === "object" &&
+ !Array.isArray(values) &&
+ !(values instanceof Date)
+ ? (values as Attributes)
+ : {},
+ });
+ (target.addEvent as (...args: unknown[]) => Span)(event, ...rest);
+ return proxy;
+ };
+ case "setStatus":
+ return (status: Parameters[0]) => {
+ if (status.code === SpanStatusCode.ERROR) observed.failed = true;
+ target.setStatus(status);
+ return proxy;
+ };
+ default:
+ return bindTo(target, property);
+ }
+ },
+ });
+ return proxy;
+}
+
+function observeTracer(tracer: Tracer): Tracer {
+ return {
+ startSpan: (name, options, context) =>
+ observeSpan(name, tracer.startSpan(name, options, context)),
+ startActiveSpan: ((name: string, ...rest: unknown[]) => {
+ const run = rest.pop() as (span: Span) => unknown;
+ return (tracer.startActiveSpan as (...args: unknown[]) => unknown)(
+ name,
+ ...rest,
+ (span: Span) => run(observeSpan(name, span)),
+ );
+ }) as Tracer["startActiveSpan"],
+ };
+}
+
+/**
+ * Let `trackRequest()` see the spans Fedify reports, while passing them on to
+ * `provider` unchanged.
+ * @returns A tracer provider to give Fedify instead.
+ */
+export function trackSpans(provider: TracerProvider): TracerProvider {
+ return {
+ getTracer: (name, version, options) =>
+ observeTracer(provider.getTracer(name, version, options)),
+ };
+}
+
+const text = (value: unknown): string =>
+ typeof value === "string" ? value : "";
+
+/** What a measurement adds to the tracked run it was made in. */
+type Note = (state: Tracked, attributes: Attributes | undefined) => void;
+
+/**
+ * The key lookup and fetches Fedify measured whose verification it has not
+ * measured yet. It reads and writes the public-key cache and counts a lookup,
+ * then measures the fetch that made them, and then the verification that
+ * fetched.
+ */
+interface Pending {
+ lookup: ObservedKeyLookup | null;
+ fetches: { readonly kind: string; readonly fetch: ObservedKeyFetch }[];
+}
+
+const pending = new WeakMap();
+
+function pendingOf(state: Tracked): Pending {
+ const known = pending.get(state);
+ if (known != null) return known;
+ const created: Pending = { lookup: null, fetches: [] };
+ pending.set(state, created);
+ return created;
+}
+
+const noteKeyLookup: Note = (state, attributes) => {
+ const status = attributes?.["http.response.status_code"];
+ pendingOf(state).lookup = {
+ result: text(attributes?.["activitypub.lookup.result"]),
+ statusCode: typeof status === "number" ? status : null,
+ };
+};
+
+const noteKeyFetch: Note = (state, attributes) => {
+ const waiting = pendingOf(state);
+ waiting.fetches.push({
+ kind: text(attributes?.["activitypub.signature.kind"]),
+ fetch: {
+ result: text(attributes?.["activitypub.signature.key_fetch.result"]),
+ lookup: waiting.lookup,
+ keys: state.keys,
+ },
+ });
+ waiting.lookup = null;
+ state.keys = new Map();
+};
+
+const noteVerification: Note = (state, attributes) => {
+ const waiting = pendingOf(state);
+ const kind = text(attributes?.["activitypub.signature.kind"]);
+ state.verifications.push({
+ kind,
+ result: text(attributes?.["activitypub.signature.result"]),
+ keyFetches: waiting.fetches
+ .filter((fetched) => fetched.kind === kind)
+ .map(({ fetch }) => fetch),
+ });
+ waiting.fetches = waiting.fetches.filter((fetched) => fetched.kind !== kind);
+};
+
+const noteOutbox: Note = (state, attributes) => {
+ state.outbox.push(text(attributes?.["activitypub.processing.result"]));
+};
+
+/** The instruments whose measurements the tracked run keeps, by how made. */
+const NOTES = {
+ createHistogram: {
+ method: "record",
+ notes: new Map([
+ [SIGNATURE_METRIC, noteVerification],
+ [KEY_FETCH_METRIC, noteKeyFetch],
+ ]),
+ },
+ createCounter: {
+ method: "add",
+ notes: new Map([
+ [OUTBOX_METRIC, noteOutbox],
+ [KEY_LOOKUP_METRIC, noteKeyLookup],
+ ]),
+ },
+} as const;
+
+function observeInstrument(
+ instrument: T,
+ method: "add" | "record",
+ note: Note,
+): T {
+ return new Proxy(instrument, {
+ get(target, property) {
+ if (property !== method) return bindTo(target, property);
+ return (value: number, attributes?: Attributes, ...rest: unknown[]) => {
+ const state = tracking();
+ if (state != null) note(state, attributes);
+ (bindTo(target, method) as (...args: unknown[]) => void)(
+ value,
+ attributes,
+ ...rest,
+ );
+ };
+ },
+ });
+}
+
+function observeMeter(meter: Meter): Meter {
+ return new Proxy(meter, {
+ get(target, property) {
+ if (property !== "createHistogram" && property !== "createCounter") {
+ return bindTo(target, property);
+ }
+ const { method, notes } = NOTES[property];
+ return (name: string, ...rest: unknown[]) => {
+ const instrument = (
+ bindTo(target, property) as (
+ ...args: unknown[]
+ ) => Counter | Histogram
+ )(name, ...rest);
+ const note = notes.get(name);
+ return note == null
+ ? instrument
+ : observeInstrument(instrument, method, note);
+ };
+ },
+ });
+}
+
+/**
+ * Let `trackRequest()` see the signature verifications, with the key fetches
+ * each made and the keys `trackPublicKeys()` saw each bring, and the outbox
+ * outcomes Fedify measures, while passing them on to `provider` unchanged.
+ * @returns A meter provider to give Fedify instead.
+ */
+export function trackMetrics(provider: MeterProvider): MeterProvider {
+ return {
+ getMeter: (name, version, options) =>
+ observeMeter(provider.getMeter(name, version, options)),
+ };
+}
+
+interface UndiciRequest {
+ readonly method: string;
+ readonly origin: string;
+ readonly path: string;
+}
+
+interface UndiciResponse {
+ readonly statusCode: number;
+ /** Header names and values, in turn. */
+ readonly headers: readonly Buffer[];
+}
+
+const header = (value: Buffer): string => value.toString("latin1");
+const ascii = (value: Buffer): boolean => value.every((byte) => byte < 0x80);
+
+/**
+ * The URLs a response's `Location` may send the request following it to,
+ * spelled as that request is: resolved against the URL answered, without a
+ * fragment. RFC 9110 keeps the value within ASCII; one outside it is read as
+ * Latin-1 by Fedify, which follows a redirect itself when it signs the
+ * request, and as UTF-8 by `fetch()`, which follows it otherwise.
+ * @returns The URLs, without one that does not parse.
+ */
+function locationsOf(headers: readonly Buffer[], base: string): string[] {
+ const index = headers.findIndex(
+ (name, position) =>
+ position % 2 === 0 && header(name).toLowerCase() === "location",
+ );
+ const value = index < 0 ? undefined : headers[index + 1];
+ if (value == null) return [];
+ const readings = ascii(value)
+ ? [header(value)]
+ : [header(value), value.toString("utf8")];
+ const urls = readings.flatMap((reading) => {
+ const url = URL.parse(reading, base);
+ if (url == null) return [];
+ url.hash = "";
+ return [url.href];
+ });
+ return [...new Set(urls)];
+}
+
+// `fetch()` creates each request in its caller's context, but a response on a
+// reused connection arrives in the connection's, so requests carry the run.
+const requests = new WeakMap();
+diagnostics.subscribe("undici:request:create", (message) => {
+ const state = tracking();
+ if (state != null) {
+ requests.set((message as { request: UndiciRequest }).request, state);
+ }
+});
+diagnostics.subscribe("undici:request:headers", (message) => {
+ const { request, response } = message as {
+ readonly request: UndiciRequest;
+ readonly response: UndiciResponse;
+ };
+ const state = requests.get(request);
+ if (state == null || state.closed) return;
+ const url = new URL(request.path, request.origin).href;
+ state.responses.push({
+ method: request.method,
+ url,
+ status: response.statusCode,
+ locations: locationsOf(response.headers, url),
+ });
+});
diff --git a/packages/graphql/src/activity-log/tracking.ts b/packages/graphql/src/activity-log/tracking.ts
new file mode 100644
index 0000000..78f1621
--- /dev/null
+++ b/packages/graphql/src/activity-log/tracking.ts
@@ -0,0 +1,264 @@
+// 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 { AsyncLocalStorage } from "node:async_hooks";
+
+import type { Uuid } from "@drfed/models/uuid";
+import type { Federation, KvKey, KvStore } from "@fedify/fedify";
+import type { Attributes } from "@opentelemetry/api";
+
+/** A span Fedify reported while a tracked run went on. */
+export interface ObservedSpan {
+ readonly name: string;
+ readonly attributes: ReadonlyMap;
+ readonly events: readonly {
+ readonly name: string;
+ readonly attributes: Readonly;
+ }[];
+ /** Whether the span ended with an error status. */
+ readonly failed: boolean;
+}
+
+/** One `activitypub.key.lookup` measurement. */
+export interface ObservedKeyLookup {
+ /** `hit`, `fetched`, `not_found`, `invalid`, `network_error`, or `error`. */
+ readonly result: string;
+ /** The status the server of the key answered with, if it answered. */
+ readonly statusCode: number | null;
+}
+
+/** One `activitypub.signature.key_fetch.duration` measurement. */
+export interface ObservedKeyFetch {
+ /** `hit`, `fetched`, or `error` when no usable key came back. */
+ readonly result: string;
+ /** The lookup Fedify counted for the fetch, which tells why it failed. */
+ readonly lookup: ObservedKeyLookup | null;
+ /**
+ * Every value each public-key cache entry held while Fedify made the fetch,
+ * in order, by its key below the prefix, as JSON: what it read, then what it
+ * wrote. The last of a fetch that brought a key is the key it brought.
+ */
+ readonly keys: ReadonlyMap;
+}
+
+/** One `activitypub.signature.verification.duration` measurement. */
+export interface ObservedVerification {
+ /** `http`, `linked_data`, or `object_integrity`. */
+ readonly kind: string;
+ /** `verified`, `rejected`, `missing`, or `error`. */
+ readonly result: string;
+ /**
+ * The key fetches Fedify measured while verifying, in order, which hold the
+ * keys this verification used, and no other. It fetches a key again when
+ * the cached one did not verify.
+ */
+ readonly keyFetches: readonly ObservedKeyFetch[];
+}
+
+/** One response `fetch()` received while a tracked run went on. */
+export interface ObservedResponse {
+ readonly method: string;
+ readonly url: string;
+ readonly status: number;
+ /**
+ * The URLs its `Location` header may name, without a fragment: one, or two
+ * for a value outside ASCII, which a follower reads as Latin-1 or as UTF-8.
+ */
+ readonly locations: readonly string[];
+}
+
+/** What a tracked run did, besides its result. */
+export interface Report {
+ /** Whether an inbox listener ran. */
+ readonly handled: boolean;
+ readonly spans: readonly ObservedSpan[];
+ readonly verifications: readonly ObservedVerification[];
+ /** `activitypub.outbox.activity` results: `retried`, `abandoned`, ... */
+ readonly outbox: readonly string[];
+ readonly responses: readonly ObservedResponse[];
+}
+
+/** The state of a tracked run, which Fedify's reports add to. */
+export interface Tracked {
+ /** The inbound log the run will be recorded as, known before it exists. */
+ readonly inboundLogId?: Uuid;
+ handled: boolean;
+ /** Tasks the run started may outlive it; they must not add to it. */
+ closed: boolean;
+ /**
+ * The values public-key cache entries held since Fedify last measured a key
+ * fetch, which the next measurement takes as those of its fetch.
+ */
+ keys: Map;
+ readonly spans: ObservedSpan[];
+ readonly verifications: ObservedVerification[];
+ readonly outbox: string[];
+ readonly responses: ObservedResponse[];
+}
+
+declare const tracked: unique symbol;
+
+/**
+ * A federation made by `createFederation()`, whose reports `trackRequest()`
+ * can see.
+ */
+export type TrackedFederation = Federation & {
+ readonly [tracked]: true;
+};
+
+const federationKv = Symbol("drfed.federationKv");
+
+/** Remember the KV store a federation made by `createFederation()` uses. */
+export function attachKv(federation: Federation, kv: KvStore): void {
+ Object.defineProperty(federation, federationKv, { value: kv });
+}
+
+/**
+ * The KV store `createFederation()` gave the federation, if it made it.
+ * @returns The store, or undefined.
+ */
+export function kvOf(federation: Federation): KvStore | undefined {
+ return (federation as { [federationKv]?: KvStore })[federationKv];
+}
+
+const storage = new AsyncLocalStorage();
+
+/**
+ * The tracked run in progress, if any.
+ * @returns Its state, or undefined outside one or after it ended.
+ */
+export function tracking(): Tracked | undefined {
+ const state = storage.getStore();
+ return state?.closed === false ? state : undefined;
+}
+
+/** Mark the tracked request as having reached an inbox listener. */
+export function markHandled(): void {
+ const state = tracking();
+ if (state != null) state.handled = true;
+}
+
+/**
+ * Run something that outlives the current tracked run, such as a queue
+ * worker, outside it.
+ * @returns The result of the run.
+ */
+export function untracked(run: () => T): T {
+ return storage.exit(run);
+}
+
+interface TrackOptions {
+ readonly inboundLogId?: Uuid;
+}
+
+/**
+ * Run a federation request or a queued task and report what Fedify did: the
+ * spans, measurements and responses it reported, with the values public-key
+ * cache entries held at each key fetch it measured, even when the run throws.
+ * @returns How the run ended, and what was tracked.
+ */
+export async function trackSettled(
+ run: () => Promise,
+ { inboundLogId }: TrackOptions = {},
+): Promise<{ readonly outcome: PromiseSettledResult } & Report> {
+ const state: Tracked = {
+ ...(inboundLogId == null ? {} : { inboundLogId }),
+ handled: false,
+ closed: false,
+ keys: new Map(),
+ spans: [],
+ verifications: [],
+ outbox: [],
+ responses: [],
+ };
+ const [outcome] = await Promise.allSettled([
+ storage.run(state, async () => await run()),
+ ]);
+ state.closed = true;
+ const { handled, spans, verifications, outbox, responses } = state;
+ return { outcome, handled, spans, verifications, outbox, responses };
+}
+
+/**
+ * End as a settled run did.
+ * @returns The result of the run, unless it threw, which is thrown again.
+ */
+export function unwrap(outcome: PromiseSettledResult): T {
+ if (outcome.status === "rejected") throw outcome.reason;
+ return outcome.value;
+}
+
+/**
+ * Run as `trackSettled()` does, for a caller that needs no report of a run
+ * that throws.
+ * @returns The result of the run, and what was tracked.
+ */
+export async function trackRequest(
+ run: () => Promise,
+ options: TrackOptions = {},
+): Promise<{ readonly result: T } & Report> {
+ const { outcome, ...report } = await trackSettled(run, options);
+ return { result: unwrap(outcome), ...report };
+}
+
+const below = (key: KvKey, prefix: KvKey): string | null =>
+ key.length > prefix.length &&
+ prefix.every((part, index) => key[index] === part)
+ ? JSON.stringify(key.slice(prefix.length))
+ : null;
+
+/**
+ * Let `trackRequest()` see the public keys Fedify reads and writes, which are
+ * the keys it verifies with, including those it fetches again. `trackMetrics()`
+ * tells which key fetch, and so which verification, each belongs to.
+ * @returns The same store, tracking entries under `prefix`.
+ */
+export function trackPublicKeys(kv: KvStore, prefix: KvKey): KvStore {
+ const note = (key: KvKey, value: unknown) => {
+ const entry = below(key, prefix);
+ const keys = tracking()?.keys;
+ if (entry != null) keys?.set(entry, [...(keys.get(entry) ?? []), value]);
+ };
+ return new Proxy(kv, {
+ get(target, property) {
+ if (property === "get") {
+ return async (key: KvKey) => {
+ const value = await target.get(key);
+ note(key, value);
+ return value;
+ };
+ }
+ if (property === "set") {
+ return async (
+ key: KvKey,
+ value: unknown,
+ options?: Parameters[2],
+ ) => {
+ await target.set(key, value, options);
+ note(key, value);
+ };
+ }
+ if (property === "delete") {
+ return async (key: KvKey) => {
+ await target.delete(key);
+ note(key, undefined);
+ };
+ }
+ const value: unknown = Reflect.get(target, property, target);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ });
+}
diff --git a/packages/graphql/src/activity-log/verification.ts b/packages/graphql/src/activity-log/verification.ts
new file mode 100644
index 0000000..4494c73
--- /dev/null
+++ b/packages/graphql/src/activity-log/verification.ts
@@ -0,0 +1,388 @@
+// 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 { observeKeyVersion } from "@drfed/models/key";
+import type {
+ ActivityLogVerificationMechanism,
+ ActivityLogVerificationResult,
+} from "@drfed/models/schema";
+import type { Uuid } from "@drfed/models/uuid";
+import { detachSignature, exportJwk } from "@fedify/fedify";
+import { Activity, type DocumentLoader } from "@fedify/vocab";
+import { getLogger } from "@logtape/logtape";
+
+import { iri } from "./describe.ts";
+import { trackedKey } from "./keycache.ts";
+import type {
+ ObservedKeyFetch,
+ ObservedSpan,
+ ObservedVerification,
+ Report,
+} from "./tracking.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+export interface VerificationObservation {
+ readonly mechanism: ActivityLogVerificationMechanism | null;
+ readonly result: ActivityLogVerificationResult;
+ readonly keyId: Uuid | null;
+ readonly signedKeyIri: string | null;
+ readonly detail: string | null;
+}
+
+/** What Fedify's report says of a verification. */
+export interface Verdict {
+ readonly mechanism: ActivityLogVerificationMechanism | null;
+ /**
+ * Whether the signature or proof verified, which is not whether Fedify took
+ * it to authenticate the activity, nor whether it accepted the activity.
+ */
+ readonly result: ActivityLogVerificationResult;
+ readonly keyIri?: string | null;
+ /**
+ * The key fetches Fedify measured for the one verification the verdict is
+ * of, which hold the key it used.
+ */
+ readonly keyFetches?: readonly ObservedKeyFetch[];
+ readonly detail?: string | null;
+}
+
+/**
+ * The signatures and proofs a document carries, whether or not tried; they
+ * count as tried only when Fedify went on to HTTP signatures.
+ */
+export interface Carried {
+ /** The key of a Linked Data Signature Fedify would try, if any. */
+ readonly ldKeyIri: string | null;
+ /** The verification method of each proof; null when not known. */
+ readonly proofMethods: readonly (string | null)[];
+}
+
+/**
+ * Mirror of the check in Fedify's inbox handler, which is not exported.
+ * @returns Whether Fedify would try Linked Data Signatures on the document.
+ */
+export function hasLdSignature(
+ json: unknown,
+): json is { signature: { creator: string } } {
+ if (typeof json !== "object" || json == null || !("signature" in json)) {
+ return false;
+ }
+ const { signature } = json;
+ return (
+ typeof signature === "object" &&
+ signature != null &&
+ "type" in signature &&
+ signature.type === "RsaSignature2017" &&
+ "creator" in signature &&
+ typeof signature.creator === "string" &&
+ "created" in signature &&
+ typeof signature.created === "string" &&
+ "signatureValue" in signature &&
+ typeof signature.signatureValue === "string"
+ );
+}
+
+/**
+ * Read the `keyId` an HTTP signature declares, without verifying.
+ * @returns The declared key IRI, or null when it is missing or not a URL.
+ */
+export function declaredKeyId(headers: Headers): string | null {
+ const authorization = headers.get("authorization");
+ const candidates = [
+ headers.get("signature-input"),
+ headers.get("signature"),
+ authorization != null && /^signature\s/iu.test(authorization)
+ ? authorization
+ : null,
+ ];
+ for (const value of candidates) {
+ const keyId = value?.match(/keyid="(?[^"]*)"/iu)?.groups?.keyId;
+ if (keyId != null) return iri(keyId);
+ }
+ return null;
+}
+
+const refuse: DocumentLoader = () =>
+ Promise.reject(new TypeError("Documents are not fetched while observing."));
+
+/**
+ * Find the proofs of a document as JSON-LD, as Fedify does, however it spells
+ * `proof`, and without fetching proofs it only references.
+ * @returns The verification method of each proof.
+ */
+export async function proofMethods(
+ json: unknown,
+ contextLoader: DocumentLoader,
+): Promise<(string | null)[]> {
+ const options = { contextLoader, documentLoader: refuse };
+ const activity = await Activity.fromJsonLd(json, options).catch(() => null);
+ if (activity == null) return [];
+ const embedded: (string | null)[] = [];
+ for await (const proof of activity.getProofs({
+ ...options,
+ suppressError: true,
+ })) {
+ embedded.push(proof.verificationMethodId?.href ?? null);
+ }
+ return [...embedded, ...activity.proofIds.map(() => null)];
+}
+
+const reportedIri = (span: ObservedSpan | undefined, attribute: string) =>
+ iri(span?.attributes.get(attribute));
+
+const failure = (kind: string, result: string | undefined) =>
+ result === "error" ? `Fedify could not verify the ${kind}.` : null;
+
+/**
+ * Why the last key fetch of a verification brought no usable key, which left
+ * the signature unchecked, or checked only against a cached key that did not
+ * verify. Fedify counts the lookup behind the fetch.
+ * @returns The status the server of the key answered with, `cached` for the
+ * record of an earlier failure, or otherwise the
+ * `activitypub.lookup.result`; null when the fetch brought a key, or
+ * none was made.
+ */
+function keyFetchFailure(
+ measured: ObservedVerification | undefined,
+): string | null {
+ const fetched = measured?.keyFetches.at(-1);
+ if (fetched?.result !== "error") return null;
+ const { lookup } = fetched;
+ if (lookup == null) return "error";
+ if (lookup.statusCode != null) return String(lookup.statusCode);
+ return lookup.result === "hit" ? "cached" : lookup.result;
+}
+
+const accepted: Pick = {
+ result: "verified",
+ detail: null,
+};
+
+/**
+ * Fedify takes proofs to authenticate an activity only when the controllers
+ * of their keys cover every actor and attribution it names.
+ */
+const unauthenticated =
+ "Every Object Integrity Proof Fedify tried verified, but it did not " +
+ "accept them as authenticating the activity.";
+
+/**
+ * Tell a verification that failed for want of a key from one whose signature
+ * did not verify.
+ * @param kind What was verified, as the detail of a verification that threw
+ * names it.
+ * @returns The result and detail of the refusal.
+ */
+function refusal(
+ kind: string,
+ measured: ObservedVerification | undefined,
+): Pick {
+ const cause = keyFetchFailure(measured);
+ return cause == null
+ ? { result: "invalid_signature", detail: failure(kind, measured?.result) }
+ : { result: "key_fetch_error", detail: `keyFetchError: ${cause}` };
+}
+
+/**
+ * Read an `http_signatures.verify` span. Fedify tries HTTP signatures only
+ * after the other mechanisms failed, so without an HTTP signature the verdict
+ * is that of the mechanism tried before.
+ * @param measured What Fedify measured of the verification.
+ * @returns The verdict of the HTTP signature, or of the mechanism before it.
+ */
+function httpVerdict(
+ http: ObservedSpan,
+ before: Verdict | null,
+ measured: ObservedVerification | undefined,
+): Verdict {
+ const tried = {
+ mechanism: "http_signature",
+ keyIri: reportedIri(http, "http_signatures.key_id"),
+ keyFetches: measured?.keyFetches ?? [],
+ } as const;
+ if (http.attributes.get("http_signatures.verified") === true) {
+ return { ...tried, result: "verified" };
+ }
+ switch (http.attributes.get("http_signatures.failure_reason")) {
+ case "noSignature":
+ return before ?? { mechanism: tried.mechanism, result: "no_signature" };
+ case "keyFetchError": {
+ const cause =
+ http.attributes.get("http_signatures.key_fetch_status") ??
+ http.attributes.get("http_signatures.key_fetch_error");
+ return {
+ ...tried,
+ result: "key_fetch_error",
+ detail: `keyFetchError: ${String(cause)}`,
+ };
+ }
+ // Fedify names only a fetch that failed; a key document that holds no key
+ // leaves the signature as unchecked.
+ default:
+ return { ...tried, ...refusal("HTTP signature", measured) };
+ }
+}
+
+/**
+ * Read the `activitypub.activity.received` event of the `activitypub.inbox`
+ * span.
+ * @returns Whether Fedify went on to handle the activity as a verified one.
+ */
+const receivedAsVerified = (spans: readonly ObservedSpan[]): boolean =>
+ spans
+ .find((span) => span.name === "activitypub.inbox")
+ ?.events.find((event) => event.name === "activitypub.activity.received")
+ ?.attributes["activitypub.activity.verified"] === true;
+
+/**
+ * Read the verification out of what Fedify documents it reports: the
+ * `activitypub.signature.verification.duration` result of each mechanism it
+ * tried, in its order, with the `activitypub.signature.key_fetch.duration` and
+ * `activitypub.key.lookup` results of the keys it fetched for each, the
+ * `*.verify` spans naming their keys, and the `activitypub.activity.received`
+ * event when it went on to handle the activity. A verdict is of whether a
+ * signature or proof verified: proofs that verify without authenticating the
+ * activity's actor are verified, whatever Fedify then answered.
+ * @returns The verdict of the mechanism that verified, or of the last tried,
+ * with the key fetches of that one verification.
+ */
+export function reportedVerdict(
+ { spans, verifications }: Pick,
+ carried: Carried,
+): Verdict {
+ const ld = spans.find((span) => span.name === "ld_signatures.verify");
+ const proofs = spans.filter(
+ (span) => span.name === "object_integrity_proofs.verify",
+ );
+ const http = spans.findLast((span) => span.name === "http_signatures.verify");
+ const measured = (kind: string) =>
+ verifications.filter((measure) => measure.kind === kind);
+ const ldMeasured = measured("linked_data").at(-1);
+ const httpMeasured = measured("http").at(-1);
+ // Fedify reports each proof it tries as a span and as a measurement, in
+ // turn, and stops at the first proof that fails, so a failure is the last.
+ const proofsMeasured = measured("object_integrity");
+ const ldVerdict = (verified: boolean): Verdict => ({
+ mechanism: "ld_signature",
+ keyIri: reportedIri(ld, "ld_signatures.key_id") ?? carried.ldKeyIri,
+ keyFetches: ldMeasured?.keyFetches ?? [],
+ ...(verified ? accepted : refusal("Linked Data Signature", ldMeasured)),
+ });
+ const proofVerdict = (verified: boolean): Verdict => {
+ const tried = proofsMeasured.find(
+ ({ result }) => (result === "verified") === verified,
+ );
+ return {
+ mechanism: "object_integrity_proof",
+ keyIri:
+ reportedIri(
+ proofs.find((proof) => proof.failed === !verified) ?? proofs[0],
+ "object_integrity_proofs.key_id",
+ ) ??
+ carried.proofMethods.find((method) => method != null) ??
+ null,
+ keyFetches: tried?.keyFetches ?? [],
+ ...(verified ? accepted : refusal("Object Integrity Proof", tried)),
+ };
+ };
+ if (ldMeasured?.result === "verified") return ldVerdict(true);
+ if (http?.attributes.get("http_signatures.verified") === true) {
+ return httpVerdict(http, null, httpMeasured);
+ }
+ // Fedify goes on to HTTP signatures unless the proofs authenticate the
+ // activity, which takes more than that every one of them verifies.
+ if (
+ proofsMeasured.length > 0 &&
+ proofsMeasured.every(({ result }) => result === "verified")
+ ) {
+ return http == null
+ ? proofVerdict(true)
+ : { ...proofVerdict(true), detail: unauthenticated };
+ }
+ // Fedify accepts an activity naming no actor or attribution vacuously.
+ if (receivedAsVerified(spans)) {
+ return { mechanism: null, result: "no_signature" };
+ }
+ // Fedify tries HTTP signatures last, so a signature or proof it reported
+ // nothing of was tried only when it went on to them.
+ const before =
+ proofsMeasured.length > 0 ||
+ (http != null && carried.proofMethods.length > 0)
+ ? proofVerdict(false)
+ : ldMeasured != null || (http != null && carried.ldKeyIri != null)
+ ? ldVerdict(false)
+ : null;
+ if (http != null) return httpVerdict(http, before, httpMeasured);
+ return before ?? { mechanism: null, result: "unattempted" };
+}
+
+/**
+ * Record what Fedify reported of verifying an inbox request, and the key
+ * version the verification the verdict is of used, from what `trackRequest()`
+ * saw it do.
+ * @param json The parsed body, or undefined when it is not JSON.
+ * @returns The observation; `unobserved` when observing itself failed.
+ */
+export async function observeVerification(
+ db: Database,
+ headers: Headers,
+ json: unknown,
+ report: Pick,
+ contextLoader: DocumentLoader,
+): Promise {
+ const signedKeyIri =
+ reportedIri(
+ report.spans.findLast((span) => span.name === "http_signatures.verify"),
+ "http_signatures.key_id",
+ ) ?? declaredKeyId(headers);
+ try {
+ const verdict = reportedVerdict(report, {
+ ldKeyIri: hasLdSignature(json) ? iri(json.signature.creator) : null,
+ proofMethods: await proofMethods(detachSignature(json), contextLoader),
+ });
+ const key =
+ verdict.keyIri == null
+ ? null
+ : await trackedKey(verdict.keyFetches ?? [], verdict.keyIri, {
+ contextLoader,
+ });
+ const version =
+ key?.publicKey == null || verdict.keyIri == null
+ ? null
+ : await observeKeyVersion(db, {
+ iri: verdict.keyIri,
+ publicKey: await exportJwk(key.publicKey),
+ });
+ return {
+ mechanism: verdict.mechanism,
+ result: verdict.result,
+ keyId: version?.id ?? null,
+ signedKeyIri,
+ detail: verdict.detail ?? null,
+ };
+ } catch (error) {
+ logger.error("Could not observe inbox verification: {error}", { error });
+ return {
+ mechanism: null,
+ result: "unobserved",
+ keyId: null,
+ signedKeyIri,
+ detail: `Verification observation failed: ${String(error)}`,
+ };
+ }
+}
diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts
index 1bc213c..72849ba 100644
--- a/packages/graphql/src/federation.test.ts
+++ b/packages/graphql/src/federation.test.ts
@@ -228,7 +228,7 @@ describe("ActivityPub resource origin spelling", () => {
new Request(requestIri, { headers: accept }),
{ contextData: undefined },
);
- assert.equal(response.status, 200);
+ assert.equal(response.status, resource === "Tombstone" ? 410 : 200);
const body = await response.json();
assert.equal(body.id, iri);
assert.equal(body.type, resource === "object" ? "Note" : resource);
@@ -329,8 +329,8 @@ describe("ActivityPub objects", () => {
new Request(object.iri, { headers: accept }),
{ contextData: undefined },
);
- // Fedify serializes generic object tombstones with HTTP 200.
- assert.equal(deleted.status, 200);
+ // Fedify serves object tombstones with HTTP 410, keeping the body.
+ assert.equal(deleted.status, 410);
const tombstone = await deleted.json();
assert.equal(tombstone.type, "Tombstone");
assert.equal(
diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts
index 94a2e15..c08270f 100644
--- a/packages/graphql/src/federation.ts
+++ b/packages/graphql/src/federation.ts
@@ -27,7 +27,6 @@ import type {
import { type Uuid, validateUuid } from "@drfed/models/uuid";
import {
type Context,
- type Federation,
type FederationBuilder,
type FederationOptions,
createFederationBuilder,
@@ -49,10 +48,29 @@ import {
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-log/outbound.ts";
+import {
+ observeQueues,
+ outboxQueue,
+ reportOutboxError,
+ reportPermanentFailure,
+} from "./activity-log/queue.ts";
+import { trackMetrics, trackSpans } from "./activity-log/telemetry.ts";
+import {
+ type TrackedFederation,
+ attachKv,
+ markHandled,
+ trackPublicKeys,
+} from "./activity-log/tracking.ts";
import { canonicalizeAuthority } from "./origin.ts";
+export { createInboundRecorder } from "./activity-log/inbound.ts";
+export type { TrackedFederation } from "./activity-log/tracking.ts";
+export { deliverActivity } from "./activity-log/outbound.ts";
+
/**
* The vocabulary object types that DrFed serves as actors.
*/
@@ -123,6 +141,8 @@ export function buildFederation(db: Database): FederationBuilder {
}
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: {
@@ -140,6 +160,7 @@ export function buildFederation(db: Database): FederationBuilder {
.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) => {
@@ -312,6 +333,7 @@ export function buildFederation(db: Database): FederationBuilder {
};
},
);
+ builder.setOutboxPermanentFailureHandler(reportPermanentFailure);
return builder;
}
@@ -319,6 +341,10 @@ export function buildFederation(db: Database): FederationBuilder {
* 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 log and each queued inbox listener run its inbound log. 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.
@@ -327,8 +353,30 @@ export function buildFederation(db: Database): FederationBuilder {
export default async function createFederation(
db: Database,
options: FederationOptions,
-): Promise> {
- return await buildFederation(db).build(options);
+): 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
diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts
index b5768af..7d2df75 100644
--- a/packages/graphql/src/harness.test.ts
+++ b/packages/graphql/src/harness.test.ts
@@ -28,6 +28,24 @@ const logger = getLogger(["drfed", "graphql", "test"]);
const testEndpoint = "https://drfed.test/graphql";
+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;
+}
+
/**
* The `fetch()` function exposed by the test Yoga server.
*/
@@ -95,9 +113,9 @@ export interface TestHarness {
/**
* Runs a callback with a fresh in-memory PGlite database.
*
- * The database is migrated before the callback is invoked, so every table in
- * the current `@drfed/models` schema is available. The underlying PGlite
- * client is closed after the callback resolves or rejects.
+ * 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.
*
* @example
* ```ts
@@ -123,10 +141,9 @@ export async function withTemporaryDatabase(
// oxlint-disable-next-line promise/prefer-await-to-callbacks
callback: (db: Database) => Promise | T,
): Promise> {
- const client = new PGlite();
+ const client = new PGlite({ loadDataDir: await getDatabaseSnapshot() });
try {
await client.waitReady;
- await migrate({ credentials: { driver: "pglite", client } });
const db: Database = drizzle({ client, relations, schema });
// oxlint-disable-next-line promise/prefer-await-to-callbacks
return await callback(db);
diff --git a/packages/graphql/src/key.ts b/packages/graphql/src/key.ts
new file mode 100644
index 0000000..974e81d
--- /dev/null
+++ b/packages/graphql/src/key.ts
@@ -0,0 +1,64 @@
+// 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 builder, { type DrFedObjectRef } from "./builder.ts";
+
+const KeyRef = builder.drizzleNode("keys", {
+ name: "Key",
+ description:
+ "A remote public key, identified by its IRI. Readable by any " +
+ "authenticated viewer, since it holds public material only.",
+ authScopes: { authenticated: true },
+ runScopesOnType: true,
+ id: { column: (key) => key.id },
+ fields: (t) => ({
+ uuid: t.expose("id", { type: "UUID" }),
+ iri: t.expose("iri", { type: "URL" }),
+ created: t.expose("created", { type: "DateTime" }),
+ versions: t.relatedConnection("versions", {
+ description: "The observed versions of the key, oldest first.",
+ query: { orderBy: { firstSeen: "asc", id: "asc" } },
+ }),
+ }),
+});
+export const Key: DrFedObjectRef = KeyRef;
+const observationDescription =
+ "DrFed observation time, not the remote key rotation time; " +
+ "does not imply continuous use between observations.";
+const KeyVersionRef = builder.drizzleNode("keyVersions", {
+ name: "KeyVersion",
+ description:
+ "Immutable public key material observed under a `Key`. Readable by " +
+ "any authenticated viewer.",
+ authScopes: { authenticated: true },
+ runScopesOnType: true,
+ id: { column: (version) => version.id },
+ fields: (t) => ({
+ uuid: t.expose("id", { type: "UUID" }),
+ key: t.relation("key"),
+ publicKey: t.expose("publicKey", { type: "JSON" }),
+ fingerprint: t.exposeString("fingerprint"),
+ firstSeen: t.expose("firstSeen", {
+ type: "DateTime",
+ description: `First ${observationDescription}`,
+ }),
+ lastSeen: t.expose("lastSeen", {
+ type: "DateTime",
+ description: `Last ${observationDescription}`,
+ }),
+ }),
+});
+export const KeyVersion: DrFedObjectRef = KeyVersionRef;
diff --git a/packages/graphql/src/origin.ts b/packages/graphql/src/origin.ts
index deb4d55..b58c81e 100644
--- a/packages/graphql/src/origin.ts
+++ b/packages/graphql/src/origin.ts
@@ -153,3 +153,15 @@ export function canonicalizeAuthority(authority: string): string {
const url = `https://${authority}`;
return URL.canParse(url) ? canonicalAuthority(new URL(url)) : authority;
}
+
+/**
+ * Composes the canonical URL of a path on an instance from its stored
+ * authority, not from the spelling a request used.
+ * @param rootOrigin The root origin of this deployment, for its scheme.
+ * @param host The stored authority of the instance, i.e. `instances.host`.
+ * @param path The absolute path of the resource.
+ * @returns The canonical URL, e.g. `https://foo-bar.drfed.net/inbox`.
+ */
+export function instanceUrl(rootOrigin: URL, host: string, path: string): URL {
+ return new URL(path, `${rootOrigin.protocol}//${host}`);
+}
diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts
index 7dc7463..0bdac2f 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -19,6 +19,8 @@ import "./instance.ts";
import "./auth/entry.ts";
import "./actor.ts";
import "./object.ts";
+import "./activity-log/entry.ts";
+import "./key.ts";
import builder from "./builder.ts";
builder.queryType({});
diff --git a/packages/models/README.md b/packages/models/README.md
index 645949a..fe5f8c8 100644
--- a/packages/models/README.md
+++ b/packages/models/README.md
@@ -28,3 +28,37 @@ mise run generate:migrate --name your_migration_name
The *drizzle/* directory is included in the published npm package so that
installed users can run migrations without access to the repository source.
+
+
+Activity log storage
+--------------------
+
+`activity_logs` stores delivery observations separately from ActivityPub
+`activities`. `@drfed/models/activity-log` exposes `recordInbound`,
+`receiveInbound`, `recordOutbound`, and `settleOutbound`. `receiveInbound`
+marks an `acknowledged` inbound log `received` once a queued inbox listener
+ran. An inbound log keeps the received
+octets in `body` and the request headers in `headers`; `payload` is the body
+parsed as JSON, and is SQL `NULL` when it does not parse or holds U+0000 or an
+unpaired surrogate, which jsonb cannot store. `error` and `response_body`
+are stored with U+FFFD for each U+0000, which text cannot store, so that a
+remote response never keeps a log from being written. `created` is when a
+request arrived or a delivery started, and `completed` when DrFed answered the
+request or the delivery last changed status.
+
+`settleOutbound` settles the most recent pending (`queued` or `failed`)
+delivery to an inbox and never changes a `sent`, `permanently_failed`, or
+`abandoned` one. Each attempt that ends is kept in `activity_log_attempts`, so
+a retry never rewrites the outcome of an earlier attempt.
+
+`activity_log_actors` relates a log to each local actor it concerns, as the
+owner of the inbox, as an addressed recipient, or as the sender, with one row
+per log and actor. Both record functions insert these rows in the same
+transaction as the log.
+
+`@drfed/models/key` exposes public-JWK validation, RFC 7638/RFC 8037 SHA-256
+thumbprints, and `observeKeyVersion`. `keys` identifies each exact key IRI;
+`key_versions` retains immutable public material per fingerprint. Returning to
+an earlier key reuses its version. Observation timestamps do not describe
+remote rotation times or continuous use. Logs retain referenced versions with
+`ON DELETE RESTRICT`, independently of Fedify cache expiry.
diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql
new file mode 100644
index 0000000..1e21679
--- /dev/null
+++ b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql
@@ -0,0 +1,96 @@
+CREATE TYPE "activity_log_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint
+CREATE TYPE "activity_log_status" AS ENUM('received', 'acknowledged', 'unverified', 'rejected', 'queued', 'sent', 'failed', 'permanently_failed', 'abandoned');--> statement-breakpoint
+CREATE TYPE "activity_log_verification_mechanism" AS ENUM('http_signature', 'ld_signature', 'object_integrity_proof');--> statement-breakpoint
+CREATE TYPE "activity_log_verification_result" AS ENUM('verified', 'invalid_signature', 'key_fetch_error', 'no_signature', 'unattempted', 'unobserved');--> statement-breakpoint
+CREATE TABLE "activity_log_actors" (
+ "log_id" uuid,
+ "actor_id" uuid,
+ "inbox_owner" boolean DEFAULT false NOT NULL,
+ "addressed" boolean DEFAULT false NOT NULL,
+ "sender" boolean DEFAULT false NOT NULL,
+ "via_collection_iri" text,
+ CONSTRAINT "activity_log_actors_pkey" PRIMARY KEY("log_id","actor_id"),
+ CONSTRAINT "activity_log_actors_role_check" CHECK ("inbox_owner" OR "addressed" OR "sender")
+);
+--> statement-breakpoint
+CREATE TABLE "activity_log_attempts" (
+ "id" uuid PRIMARY KEY,
+ "log_id" uuid NOT NULL,
+ "succeeded" boolean NOT NULL,
+ "status_code" integer,
+ "response_body" text,
+ "error" text,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ CONSTRAINT "activity_log_attempts_status_code_check" CHECK ("status_code" IS NULL OR "status_code" BETWEEN 100 AND 599),
+ CONSTRAINT "activity_log_attempts_error_check" CHECK ("succeeded" = ("error" IS NULL))
+);
+--> statement-breakpoint
+CREATE TABLE "activity_logs" (
+ "id" uuid PRIMARY KEY,
+ "instance_id" uuid NOT NULL,
+ "actor_id" uuid,
+ "direction" "activity_log_direction" NOT NULL,
+ "status" "activity_log_status" NOT NULL,
+ "verification_mechanism" "activity_log_verification_mechanism",
+ "verification_result" "activity_log_verification_result",
+ "type" text,
+ "types" text[] DEFAULT '{}'::text[] NOT NULL,
+ "activity_iri" text,
+ "object_type" text,
+ "object_iri" text,
+ "signed_key_iri" text,
+ "verification_key_id" uuid,
+ "remote_actor_iri" text,
+ "remote_host" text,
+ "inbox_url" text NOT NULL,
+ "request_url" text,
+ "headers" jsonb,
+ "body" bytea,
+ "status_code" integer,
+ "response_body" text,
+ "error" text,
+ "payload" jsonb,
+ "recipient_iris" text[] DEFAULT '{}'::text[] NOT NULL,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ "completed" timestamp with time zone,
+ CONSTRAINT "activity_logs_direction_status_check" CHECK (("direction" = 'inbound' AND "status" IN ('received', 'acknowledged', 'unverified', 'rejected')) OR ("direction" = 'outbound' AND "status" IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))),
+ CONSTRAINT "activity_logs_completed_check" CHECK (("completed" IS NULL) = ("status" = 'queued')),
+ CONSTRAINT "activity_logs_status_code_check" CHECK ("status_code" IS NULL OR "status_code" BETWEEN 100 AND 599),
+ CONSTRAINT "activity_logs_outbound_key_check" CHECK ("direction" <> 'outbound' OR "verification_key_id" IS NULL),
+ CONSTRAINT "activity_logs_verification_result_check" CHECK (("direction" = 'inbound') = ("verification_result" IS NOT NULL)),
+ CONSTRAINT "activity_logs_verification_mechanism_check" CHECK ("verification_mechanism" IS NULL OR ("direction" = 'inbound' AND "verification_result" NOT IN ('unattempted', 'unobserved'))),
+ CONSTRAINT "activity_logs_body_check" CHECK (("direction" = 'inbound') = ("body" IS NOT NULL))
+);
+--> statement-breakpoint
+CREATE TABLE "key_versions" (
+ "id" uuid PRIMARY KEY,
+ "key_id" uuid NOT NULL,
+ "public_key" jsonb NOT NULL,
+ "fingerprint" text NOT NULL,
+ "first_seen" timestamp with time zone NOT NULL,
+ "last_seen" timestamp with time zone NOT NULL,
+ CONSTRAINT "key_versions_key_id_fingerprint_unique" UNIQUE("key_id","fingerprint"),
+ CONSTRAINT "key_versions_seen_check" CHECK ("last_seen" >= "first_seen"),
+ CONSTRAINT "key_versions_public_key_check" CHECK (NOT ("public_key" ?| array['d','p','q','dp','dq','qi','oth','k']))
+);
+--> statement-breakpoint
+CREATE TABLE "keys" (
+ "id" uuid PRIMARY KEY,
+ "iri" text NOT NULL UNIQUE,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX "activity_log_actor_log_index" ON "activity_log_actors" ("actor_id","log_id" desc);--> statement-breakpoint
+CREATE INDEX "activity_log_attempt_log_index" ON "activity_log_attempts" ("log_id","created","id");--> statement-breakpoint
+CREATE INDEX "activity_log_instance_created_index" ON "activity_logs" ("instance_id","created" desc,"id" desc);--> statement-breakpoint
+CREATE INDEX "activity_log_actor_index" ON "activity_logs" ("actor_id");--> statement-breakpoint
+CREATE INDEX "activity_log_verification_key_index" ON "activity_logs" ("verification_key_id");--> statement-breakpoint
+CREATE INDEX "activity_log_outbound_index" ON "activity_logs" ("activity_iri","inbox_url") WHERE "direction" = 'outbound';--> statement-breakpoint
+CREATE INDEX "key_version_key_first_seen_index" ON "key_versions" ("key_id","first_seen","id");--> statement-breakpoint
+ALTER TABLE "activity_log_actors" ADD CONSTRAINT "activity_log_actors_log_id_activity_logs_id_fkey" FOREIGN KEY ("log_id") REFERENCES "activity_logs"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "activity_log_actors" ADD CONSTRAINT "activity_log_actors_actor_id_actors_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "activity_log_attempts" ADD CONSTRAINT "activity_log_attempts_log_id_activity_logs_id_fkey" FOREIGN KEY ("log_id") REFERENCES "activity_logs"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_instance_id_instances_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint
+ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_actor_id_actors_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "actors"("id") ON DELETE SET NULL;--> statement-breakpoint
+ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_verification_key_id_key_versions_id_fkey" FOREIGN KEY ("verification_key_id") REFERENCES "key_versions"("id") ON DELETE RESTRICT;--> statement-breakpoint
+ALTER TABLE "key_versions" ADD CONSTRAINT "key_versions_key_id_keys_id_fkey" FOREIGN KEY ("key_id") REFERENCES "keys"("id") ON DELETE RESTRICT;
\ No newline at end of file
diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
new file mode 100644
index 0000000..009c741
--- /dev/null
+++ b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
@@ -0,0 +1,3445 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "10a7ac3c-ffbe-46a9-8ea9-4f50ee544b12",
+ "prevIds": [
+ "478b925d-8ac8-466c-9804-bb055fdd6489",
+ "7c4a0afb-efbc-4ae7-b6b5-ebc6daaddb3e"
+ ],
+ "ddl": [
+ {
+ "values": ["inbound", "outbound"],
+ "name": "activity_log_direction",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": [
+ "received",
+ "acknowledged",
+ "unverified",
+ "rejected",
+ "queued",
+ "sent",
+ "failed",
+ "permanently_failed",
+ "abandoned"
+ ],
+ "name": "activity_log_status",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["http_signature", "ld_signature", "object_integrity_proof"],
+ "name": "activity_log_verification_mechanism",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": [
+ "verified",
+ "invalid_signature",
+ "key_fetch_error",
+ "no_signature",
+ "unattempted",
+ "unobserved"
+ ],
+ "name": "activity_log_verification_result",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["Create"],
+ "name": "activity_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["Application", "Group", "Organization", "Person", "Service"],
+ "name": "actor_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["to", "cc", "bto", "bcc", "audience"],
+ "name": "addressing_property",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["followers", "following", "featured", "outbox"],
+ "name": "collection_role",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["Collection", "OrderedCollection"],
+ "name": "collection_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["Article", "Note"],
+ "name": "object_type",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "values": ["actor", "object", "activity", "collection", "unknown"],
+ "name": "resource_kind",
+ "entityType": "enums",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "accounts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "activities",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "activity_log_actors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "activity_log_attempts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "activity_logs",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "actor_collection_references",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "actors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "addressing",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "collection_items",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "collections",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instance_members",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "instances",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "key_versions",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "keys",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "local_actors",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "local_instances",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "login_challenges",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "objects",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "resources",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "sessions",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "email",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "10",
+ "generated": null,
+ "identity": null,
+ "name": "max_instances",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "activity_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "objectId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "published",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "document",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "log_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actor_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "inbox_owner",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "addressed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "sender",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "via_collection_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "log_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "succeeded",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "status_code",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "response_body",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instance_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actor_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "activity_log_direction",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "direction",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "activity_log_status",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "activity_log_verification_mechanism",
+ "typeSchema": "public",
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "verification_mechanism",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "activity_log_verification_result",
+ "typeSchema": "public",
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "verification_result",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "types",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "activity_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "object_type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "object_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "signed_key_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "verification_key_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_actor_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_host",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "inbox_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "request_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "headers",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "bytea",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "body",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "status_code",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "response_body",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "payload",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "recipient_iris",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "completed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "type": "collection_role",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "role",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "collectionId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "localId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "actor_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "username",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instanceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "document",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "inboxUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "profileUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "avatarUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "headerUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "bioHtml",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "automaticallyApprovesFollowers",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "fieldHtmls",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "emojis",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'{}'",
+ "generated": null,
+ "identity": null,
+ "name": "tags",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "sensitive",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "suspended",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "suspendedUntil",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "successorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 1,
+ "default": "(ARRAY[]::text[])",
+ "generated": null,
+ "identity": null,
+ "name": "aliases",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "followingCount",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "followersCount",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "updated",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "published",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "deleted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "sourceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "type": "addressing_property",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "property",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "position",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "targetId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "collectionId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "itemId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "position",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "observed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "collection_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ownerActorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "totalItems",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "document",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "updated",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "instanceId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "admin",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accepted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "localId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "varchar(259)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "host",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "nodeInfoUrl",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "software",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "softwareVersion",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "key_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "fingerprint",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "first_seen",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "last_seen",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "keys"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "keys"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "keys"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "avatar",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "header",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_actors"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "varchar(63)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "slug",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "10",
+ "generated": null,
+ "identity": null,
+ "name": "maxActors",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "char(6)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "code",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "consumed",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actorId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "object_type",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "document",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "summary",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "contentHtml",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "varchar(35)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "language",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "sensitive",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "published",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "updated",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "deleted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "resources"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "resources"
+ },
+ {
+ "type": "resource_kind",
+ "typeSchema": "public",
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "kind",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "resources"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "resources"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tokenHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP",
+ "generated": null,
+ "identity": null,
+ "name": "created",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "type": "timestamp with time zone",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'",
+ "generated": null,
+ "identity": null,
+ "name": "expires",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "actorId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"published\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"id\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_actor_published_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "objectId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "published",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_object_published_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"log_id\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_actor_log_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "log_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "created",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_attempt_log_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"created\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"id\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_instance_created_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_actor_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "verification_key_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_verification_key_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "activity_iri",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "inbox_url",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"direction\" = 'outbound'",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "activity_log_outbound_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "collectionId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "actor_collection_reference_collection_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "instanceId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "actor_instance_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "targetId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "property",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "addressing_target_property_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "collectionId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "position",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "collection_item_position_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "accountId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_accountId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ {
+ "value": "instanceId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": "\"accepted\" IS NOT NULL",
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "instance_members_instanceId_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "first_seen",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "key_version_key_first_seen_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "actorId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"published\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "\"id\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "object_actor_published_index",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["id"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activities_id_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["actorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activities_actorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["objectId"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activities_objectId_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activities"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["log_id"],
+ "schemaTo": "public",
+ "tableTo": "activity_logs",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activity_log_actors_log_id_activity_logs_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["actor_id"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activity_log_actors_actor_id_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["log_id"],
+ "schemaTo": "public",
+ "tableTo": "activity_logs",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activity_log_attempts_log_id_activity_logs_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instance_id"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activity_logs_instance_id_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["actor_id"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "name": "activity_logs_actor_id_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["verification_key_id"],
+ "schemaTo": "public",
+ "tableTo": "key_versions",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "RESTRICT",
+ "name": "activity_logs_verification_key_id_key_versions_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["actorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actor_collection_references_actorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["collectionId"],
+ "schemaTo": "public",
+ "tableTo": "collections",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actor_collection_references_collectionId_collections_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["id"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actors_id_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "schemaTo": "public",
+ "tableTo": "local_actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actors_localId_local_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instanceId"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "actors_instanceId_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["successorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "name": "actors_successorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["sourceId"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "addressing_sourceId_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["targetId"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "RESTRICT",
+ "name": "addressing_targetId_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["collectionId"],
+ "schemaTo": "public",
+ "tableTo": "collections",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "collection_items_collectionId_collections_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["itemId"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "collection_items_itemId_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["id"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "collections_id_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["ownerActorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "collections_ownerActorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "collections"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["instanceId"],
+ "schemaTo": "public",
+ "tableTo": "instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "name": "instance_members_instanceId_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "schemaTo": "public",
+ "tableTo": "local_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "instances_localId_local_instances_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "instances"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["key_id"],
+ "schemaTo": "public",
+ "tableTo": "keys",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "RESTRICT",
+ "name": "key_versions_key_id_keys_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "login_tokens_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "login_challenges"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["id"],
+ "schemaTo": "public",
+ "tableTo": "resources",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "objects_id_resources_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["actorId"],
+ "schemaTo": "public",
+ "tableTo": "actors",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "objects_actorId_actors_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "objects"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["accountId"],
+ "schemaTo": "public",
+ "tableTo": "accounts",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "sessions_accountId_accounts_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "sessions"
+ },
+ {
+ "columns": ["log_id", "actor_id"],
+ "nameExplicit": false,
+ "name": "activity_log_actors_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "columns": ["actorId", "role"],
+ "nameExplicit": false,
+ "name": "actor_collection_references_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "actor_collection_references"
+ },
+ {
+ "columns": ["collectionId", "itemId"],
+ "nameExplicit": false,
+ "name": "collection_items_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "collection_items"
+ },
+ {
+ "columns": ["instanceId", "accountId"],
+ "nameExplicit": false,
+ "name": "instance_members_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "instance_members"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "accounts_pkey",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "activities_pkey",
+ "schema": "public",
+ "table": "activities",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "activity_log_attempts_pkey",
+ "schema": "public",
+ "table": "activity_log_attempts",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "activity_logs_pkey",
+ "schema": "public",
+ "table": "activity_logs",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "actors_pkey",
+ "schema": "public",
+ "table": "actors",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "addressing_pkey",
+ "schema": "public",
+ "table": "addressing",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "collections_pkey",
+ "schema": "public",
+ "table": "collections",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "instances_pkey",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "key_versions_pkey",
+ "schema": "public",
+ "table": "key_versions",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "keys_pkey",
+ "schema": "public",
+ "table": "keys",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "local_actors_pkey",
+ "schema": "public",
+ "table": "local_actors",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "local_instances_pkey",
+ "schema": "public",
+ "table": "local_instances",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "login_tokens_pkey",
+ "schema": "public",
+ "table": "login_challenges",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "objects_pkey",
+ "schema": "public",
+ "table": "objects",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "resources_pkey",
+ "schema": "public",
+ "table": "resources",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "sessions_pkey",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": true,
+ "columns": ["username", "instanceId"],
+ "nullsNotDistinct": false,
+ "name": "username_key",
+ "entityType": "uniques",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "nameExplicit": true,
+ "columns": ["sourceId", "property", "position"],
+ "nullsNotDistinct": false,
+ "name": "addressing_source_property_position_key",
+ "entityType": "uniques",
+ "schema": "public",
+ "table": "addressing"
+ },
+ {
+ "nameExplicit": true,
+ "columns": ["key_id", "fingerprint"],
+ "nullsNotDistinct": false,
+ "name": "key_versions_key_id_fingerprint_unique",
+ "entityType": "uniques",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["email"],
+ "nullsNotDistinct": false,
+ "name": "accounts_email_key",
+ "schema": "public",
+ "table": "accounts",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["localId"],
+ "nullsNotDistinct": false,
+ "name": "actors_localId_key",
+ "schema": "public",
+ "table": "actors",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["host"],
+ "nullsNotDistinct": false,
+ "name": "instances_host_key",
+ "schema": "public",
+ "table": "instances",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["iri"],
+ "nullsNotDistinct": false,
+ "name": "keys_iri_key",
+ "schema": "public",
+ "table": "keys",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["slug"],
+ "nullsNotDistinct": false,
+ "name": "local_instances_slug_key",
+ "schema": "public",
+ "table": "local_instances",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["iri"],
+ "nullsNotDistinct": false,
+ "name": "resources_iri_key",
+ "schema": "public",
+ "table": "resources",
+ "entityType": "uniques"
+ },
+ {
+ "nameExplicit": false,
+ "columns": ["tokenHash"],
+ "nullsNotDistinct": false,
+ "name": "sessions_tokenHash_key",
+ "schema": "public",
+ "table": "sessions",
+ "entityType": "uniques"
+ },
+ {
+ "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'",
+ "name": "accounts_email_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "\"max_instances\" >= 0",
+ "name": "accounts_max_instances_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "trim(both from \"name\") <> ''",
+ "name": "accounts_name_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "accounts"
+ },
+ {
+ "value": "\"inbox_owner\" OR \"addressed\" OR \"sender\"",
+ "name": "activity_log_actors_role_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
+ {
+ "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599",
+ "name": "activity_log_attempts_status_code_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "value": "\"succeeded\" = (\"error\" IS NULL)",
+ "name": "activity_log_attempts_error_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_log_attempts"
+ },
+ {
+ "value": "(\"direction\" = 'inbound' AND \"status\" IN ('received', 'acknowledged', 'unverified', 'rejected')) OR (\"direction\" = 'outbound' AND \"status\" IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))",
+ "name": "activity_logs_direction_status_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "(\"completed\" IS NULL) = (\"status\" = 'queued')",
+ "name": "activity_logs_completed_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599",
+ "name": "activity_logs_status_code_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "\"direction\" <> 'outbound' OR \"verification_key_id\" IS NULL",
+ "name": "activity_logs_outbound_key_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "(\"direction\" = 'inbound') = (\"verification_result\" IS NOT NULL)",
+ "name": "activity_logs_verification_result_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "\"verification_mechanism\" IS NULL OR (\"direction\" = 'inbound' AND \"verification_result\" NOT IN ('unattempted', 'unobserved'))",
+ "name": "activity_logs_verification_mechanism_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "(\"direction\" = 'inbound') = (\"body\" IS NOT NULL)",
+ "name": "activity_logs_body_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "value": "\"username\" NOT LIKE '%@%'",
+ "name": "actors_username_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ",
+ "name": "actors_suspended_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "actors"
+ },
+ {
+ "value": "\"last_seen\" >= \"first_seen\"",
+ "name": "key_versions_seen_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "value": "NOT (\"public_key\" ?| array['d','p','q','dp','dq','qi','oth','k'])",
+ "name": "key_versions_public_key_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "key_versions"
+ },
+ {
+ "value": "\"slug\" ~ '^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$'\n AND (\"slug\" !~ '^..--' OR \"slug\" ~ '^xn--')",
+ "name": "local_instances_slug_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "value": "\"maxActors\" > 0",
+ "name": "instances_max_actors_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "local_instances"
+ },
+ {
+ "value": "trim(both from \"contentHtml\") <> ''",
+ "name": "objects_content_html_check",
+ "entityType": "checks",
+ "schema": "public",
+ "table": "objects"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/models/package.json b/packages/models/package.json
index 44e366b..9d3cefe 100644
--- a/packages/models/package.json
+++ b/packages/models/package.json
@@ -81,6 +81,14 @@
"./slug": {
"types": "./dist/slug.d.mts",
"default": "./dist/slug.mjs"
+ },
+ "./key": {
+ "types": "./dist/key.d.mts",
+ "default": "./dist/key.mjs"
+ },
+ "./activity-log": {
+ "types": "./dist/activity-log.d.mts",
+ "default": "./dist/activity-log.mjs"
}
},
"files": [
@@ -99,7 +107,9 @@
"src/uuid.ts",
"src/login.ts",
"src/resource.ts",
- "src/slug.ts"
+ "src/slug.ts",
+ "src/key.ts",
+ "src/activity-log.ts"
],
"dts": {
"sourcemap": true,
diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts
new file mode 100644
index 0000000..56f88b2
--- /dev/null
+++ b/packages/models/src/activity-log.test.ts
@@ -0,0 +1,435 @@
+// 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 .
+
+// oxlint-disable id-length max-statements no-await-in-loop
+
+import assert from "node:assert/strict";
+import { it } from "node:test";
+
+import { migrate, relations, schema } from "@drfed/models";
+import {
+ type OutboundSettlement,
+ inboundActorRows,
+ receiveInbound,
+ recordInbound,
+ recordOutbound,
+ settleOutbound,
+} from "@drfed/models/activity-log";
+import { observeKeyVersion } from "@drfed/models/key";
+import { uuidV7 } from "@drfed/models/uuid";
+import { PGlite } from "@electric-sql/pglite";
+import { and, eq, isNull } from "drizzle-orm";
+import { drizzle } from "drizzle-orm/pglite";
+
+it("enforces log constraints, key retention and outbound state transitions", async () => {
+ const client = new PGlite();
+ try {
+ await migrate({ credentials: { driver: "pglite", client } });
+ const db = drizzle({ client, schema, relations });
+ const instanceId = uuidV7();
+ await db
+ .insert(schema.instances)
+ .values({ id: instanceId, host: "local.example" });
+ const version = await observeKeyVersion(db, {
+ iri: "https://remote.example/key",
+ publicKey: { kty: "RSA", e: "AQAB", n: "test" },
+ });
+ const entry = {
+ instanceId,
+ inboxUrl: "https://local.example/inbox",
+ payload: { type: "Create", bcc: ["private"] },
+ };
+ const body = new TextEncoder().encode('{"type":"Create","type":"Follow"}');
+ const now = Temporal.Now.instant();
+ const observed = {
+ verificationResult: "invalid_signature",
+ body,
+ created: now,
+ completed: now,
+ } as const;
+ const inbound = await recordInbound(db, {
+ ...entry,
+ ...observed,
+ status: "unverified",
+ verificationKeyId: version.id,
+ });
+ assert.deepEqual(inbound.payload, entry.payload);
+ assert.deepEqual(new Uint8Array(inbound.body!), body);
+ const presetId = uuidV7();
+ const acknowledged = await recordInbound(db, {
+ ...entry,
+ ...observed,
+ id: presetId,
+ status: "acknowledged",
+ });
+ assert.equal(acknowledged.id, presetId);
+ assert.equal(await receiveInbound(db, presetId), true);
+ assert.equal(
+ (await db.query.activityLogs.findFirst({ where: { id: presetId } }))
+ ?.status,
+ "received",
+ );
+ // Only an acknowledged inbound log is received later.
+ assert.equal(await receiveInbound(db, presetId), false);
+ assert.equal(await receiveInbound(db, inbound.id), false);
+ const unparsed = await recordInbound(db, {
+ ...entry,
+ ...observed,
+ payload: undefined,
+ status: "unverified",
+ error: "threw\u0000",
+ responseBody: "\u0000",
+ });
+ // Nor does text hold U+0000, which an error or a response may carry.
+ assert.deepEqual(
+ [unparsed.error, unparsed.responseBody],
+ ["threw\ufffd", "\ufffd"],
+ );
+ assert.equal(
+ await db.$count(
+ schema.activityLogs,
+ and(
+ eq(schema.activityLogs.id, unparsed.id),
+ isNull(schema.activityLogs.payload),
+ ),
+ ),
+ 1,
+ );
+ const inboundRow = {
+ ...entry,
+ ...observed,
+ id: uuidV7(),
+ direction: "inbound",
+ status: "received",
+ body: Buffer.from(body),
+ } as const;
+ await assert.rejects(
+ db
+ .insert(schema.activityLogs)
+ .values({ ...inboundRow, verificationResult: null }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({ ...inboundRow, body: null }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({ ...inboundRow, completed: null }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({
+ ...entry,
+ id: uuidV7(),
+ direction: "outbound",
+ status: "queued",
+ verificationResult: "verified",
+ }),
+ );
+ await assert.rejects(
+ db
+ .delete(schema.keyVersions)
+ .where(eq(schema.keyVersions.id, version.id)),
+ );
+ await assert.rejects(
+ db.delete(schema.keys).where(eq(schema.keys.id, version.keyId)),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({ ...inboundRow, status: "sent" }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({
+ ...entry,
+ id: uuidV7(),
+ direction: "outbound",
+ status: "queued",
+ verificationKeyId: version.id,
+ }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogs).values({ ...inboundRow, statusCode: 600 }),
+ );
+ const outgoing = {
+ ...entry,
+ activityIri: "https://local.example/activity/1",
+ };
+ const row = await recordOutbound(db, outgoing);
+ const current = (id = row.id) =>
+ db.query.activityLogs.findFirst({
+ where: { id },
+ with: { attempts: { orderBy: { created: "asc", id: "asc" } } },
+ });
+ const attempts = async (id = row.id) =>
+ ((await current(id))?.attempts ?? []).map((attempt) => [
+ attempt.succeeded,
+ attempt.statusCode,
+ attempt.error,
+ attempt.responseBody,
+ ]);
+ assert.equal((await current())?.status, "queued");
+ assert.equal((await current())?.completed, null);
+ await assert.rejects(
+ db
+ .update(schema.activityLogs)
+ .set({ completed: now })
+ .where(eq(schema.activityLogs.id, row.id)),
+ );
+ assert.equal(
+ await settleOutbound(db, {
+ ...outgoing,
+ status: "failed",
+ statusCode: 503,
+ error: "Unavailable",
+ responseBody: "Later",
+ attempted: true,
+ }),
+ true,
+ );
+ assert.equal((await current())?.statusCode, 503);
+ await settleOutbound(db, { ...outgoing, status: "sent", attempted: true });
+ const sent = await current();
+ assert.equal(sent?.status, "sent");
+ assert.deepEqual(
+ [sent?.statusCode, sent?.error, sent?.responseBody],
+ [null, null, null],
+ );
+ assert.ok(sent?.completed != null);
+ assert.deepEqual(await attempts(), [
+ [false, 503, "Unavailable", "Later"],
+ [true, null, null, null],
+ ]);
+ assert.equal(
+ await settleOutbound(db, {
+ ...outgoing,
+ status: "permanently_failed",
+ statusCode: 410,
+ error: "Gone",
+ attempted: true,
+ }),
+ false,
+ );
+ assert.equal((await current())?.status, "sent");
+ assert.equal((await attempts()).length, 2);
+ const [attempt] = (await current())?.attempts ?? [];
+ await assert.rejects(
+ db.insert(schema.activityLogAttempts).values({
+ ...attempt!,
+ id: uuidV7(),
+ succeeded: true,
+ error: "Unavailable",
+ }),
+ );
+ await assert.rejects(
+ db
+ .insert(schema.activityLogAttempts)
+ .values({ ...attempt!, id: uuidV7(), statusCode: 600 }),
+ );
+ const final = async (
+ activityIri: string,
+ settlement: Omit,
+ ) => {
+ const log = await recordOutbound(db, { ...outgoing, activityIri });
+ await settleOutbound(db, { ...outgoing, ...settlement, activityIri });
+ return log.id;
+ };
+ const abandoned = await final("https://local.example/activity/2", {
+ status: "abandoned",
+ statusCode: 503,
+ error: "Unavailable",
+ attempted: true,
+ });
+ assert.equal((await current(abandoned))?.status, "abandoned");
+ assert.deepEqual(await attempts(abandoned), [
+ [false, 503, "Unavailable", null],
+ ]);
+ const dropped = await final("https://local.example/activity/3", {
+ status: "permanently_failed",
+ error: "Circuit breaker held activity expired.",
+ attempted: false,
+ });
+ assert.equal((await current(dropped))?.status, "permanently_failed");
+ assert.ok((await current(dropped))?.completed != null);
+ assert.deepEqual(await attempts(dropped), []);
+ const unstorable = await final("https://local.example/activity/5", {
+ status: "failed",
+ statusCode: 500,
+ error: "Failed:\nerror\u0000details",
+ responseBody: "error\u0000details",
+ attempted: true,
+ });
+ assert.deepEqual(
+ [
+ (await current(unstorable))?.status,
+ (await current(unstorable))?.responseBody,
+ ],
+ ["failed", "error\ufffddetails"],
+ );
+ assert.deepEqual(await attempts(unstorable), [
+ [false, 500, "Failed:\nerror\ufffddetails", "error\ufffddetails"],
+ ]);
+ const retried = {
+ ...outgoing,
+ activityIri: "https://local.example/activity/4",
+ };
+ const deliveries = [];
+ for (const [statusCode, error] of [
+ [503, "First"],
+ [502, "Second"],
+ ] as const) {
+ deliveries.push(await recordOutbound(db, retried));
+ await settleOutbound(db, {
+ ...retried,
+ status: "failed",
+ statusCode,
+ error,
+ attempted: true,
+ });
+ }
+ assert.deepEqual(
+ (
+ await db.query.activityLogs.findMany({
+ where: { activityIri: retried.activityIri },
+ orderBy: { id: "asc" },
+ })
+ ).map((log) => [log.id, log.status, log.statusCode, log.error]),
+ [
+ [deliveries[0]!.id, "failed", 503, "First"],
+ [deliveries[1]!.id, "failed", 502, "Second"],
+ ],
+ );
+ assert.equal(
+ await settleOutbound(db, {
+ ...retried,
+ id: deliveries[0]!.id,
+ status: "sent",
+ attempted: true,
+ }),
+ true,
+ );
+ assert.equal((await current(deliveries[1]!.id))?.status, "failed");
+ await db
+ .delete(schema.instances)
+ .where(eq(schema.instances.id, instanceId));
+ assert.equal(await db.$count(schema.activityLogs), 0);
+ assert.equal(await db.$count(schema.activityLogAttempts), 0);
+ await db
+ .delete(schema.keyVersions)
+ .where(eq(schema.keyVersions.id, version.id));
+ } finally {
+ await client.close();
+ }
+});
+
+it("keeps one row per log and actor and requires a role", async () => {
+ const client = new PGlite();
+ try {
+ await migrate({ credentials: { driver: "pglite", client } });
+ const db = drizzle({ client, schema, relations });
+ const instanceId = uuidV7();
+ const actorId = uuidV7();
+ const otherId = uuidV7();
+ await db
+ .insert(schema.instances)
+ .values({ id: instanceId, host: "local.example" });
+ for (const [id, username] of [
+ [actorId, "alice"],
+ [otherId, "bob"],
+ ] as const) {
+ const iri = `https://local.example/users/${id}`;
+ await db.insert(schema.resources).values({ id, iri, kind: "actor" });
+ await db.insert(schema.actors).values({
+ id,
+ type: "Person",
+ username,
+ instanceId,
+ inboxUrl: `${iri}/inbox`,
+ });
+ }
+ const collection = "https://local.example/collections/1";
+ assert.deepEqual(
+ inboundActorRows({
+ actorId,
+ addressed: [
+ { actorId, viaCollectionIri: collection },
+ { actorId },
+ { actorId: otherId, viaCollectionIri: collection },
+ ],
+ }),
+ [
+ { actorId, inboxOwner: true, addressed: true, viaCollectionIri: null },
+ { actorId: otherId, addressed: true, viaCollectionIri: collection },
+ ],
+ );
+ const log = await recordInbound(db, {
+ instanceId,
+ actorId,
+ inboxUrl: `https://local.example/users/${actorId}/inbox`,
+ status: "received",
+ verificationResult: "verified",
+ body: new TextEncoder().encode("{}"),
+ payload: {},
+ addressed: [{ actorId }, { actorId: otherId }],
+ created: Temporal.Now.instant(),
+ completed: Temporal.Now.instant(),
+ });
+ const sent = await recordOutbound(db, {
+ instanceId,
+ actorId,
+ inboxUrl: "https://remote.example/inbox",
+ activityIri: "https://local.example/activity/1",
+ payload: {},
+ recipientIris: ["https://remote.example/a", "https://remote.example/b"],
+ });
+ assert.deepEqual(sent.recipientIris, [
+ "https://remote.example/a",
+ "https://remote.example/b",
+ ]);
+ const links = await db.query.activityLogActors.findMany({
+ orderBy: { logId: "asc", actorId: "asc" },
+ });
+ assert.deepEqual(
+ links.map((link) => [
+ link.logId,
+ link.actorId,
+ link.inboxOwner,
+ link.addressed,
+ link.sender,
+ ]),
+ [
+ [log.id, actorId, true, true, false],
+ [log.id, otherId, false, true, false],
+ [sent.id, actorId, false, false, true],
+ ],
+ );
+ await assert.rejects(
+ db
+ .insert(schema.activityLogActors)
+ .values({ logId: log.id, actorId, sender: true }),
+ );
+ await assert.rejects(
+ db
+ .insert(schema.activityLogActors)
+ .values({ logId: sent.id, actorId: otherId }),
+ );
+ const found = await db.query.actors.findFirst({
+ where: { id: actorId },
+ with: { activityLogs: { orderBy: { created: "desc", id: "desc" } } },
+ });
+ assert.deepEqual(
+ found?.activityLogs.map((row) => row.id),
+ [sent.id, log.id],
+ );
+ } finally {
+ await client.close();
+ }
+});
diff --git a/packages/models/src/activity-log.ts b/packages/models/src/activity-log.ts
new file mode 100644
index 0000000..72d317a
--- /dev/null
+++ b/packages/models/src/activity-log.ts
@@ -0,0 +1,287 @@
+// 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 { and, desc, eq, inArray, sql } from "drizzle-orm";
+
+import type { Database } from "./db.ts";
+import {
+ type ActivityLog,
+ type NewActivityLog,
+ type NewActivityLogActor,
+ activityLogActors,
+ activityLogAttempts,
+ activityLogs,
+} from "./schema.ts";
+import { type Uuid, uuidV7 } from "./uuid.ts";
+
+type LogEntry = Omit;
+
+export interface AddressedActor {
+ readonly actorId: Uuid;
+ readonly viaCollectionIri?: string | null;
+}
+
+/**
+ * `payload` is `undefined` for an unparsable body and `null` for JSON `null`;
+ * it is stored as SQL `NULL` when jsonb cannot hold it, and `body` keeps it.
+ */
+export type InboundLogEntry = Omit<
+ LogEntry,
+ "body" | "verificationResult" | "recipientIris"
+> & {
+ /** Chosen in advance when something must name the log before it exists. */
+ readonly id?: Uuid;
+ readonly status: "received" | "acknowledged" | "unverified" | "rejected";
+ readonly verificationResult: NonNullable;
+ readonly body: Uint8Array;
+ readonly addressed?: readonly AddressedActor[];
+ /** When the request arrived, before verification and handling. */
+ readonly created: Temporal.Instant;
+ /** When DrFed answered the request. */
+ readonly completed: Temporal.Instant;
+};
+
+/** `payload` is the document before signing, without `bto` and `bcc`. */
+export type OutboundLogEntry = Omit<
+ LogEntry,
+ | "verificationKeyId"
+ | "verificationMechanism"
+ | "verificationResult"
+ | "body"
+ | "headers"
+ | "requestUrl"
+ | "completed"
+> & {
+ readonly activityIri: string;
+};
+
+type ActorRow = Omit;
+
+/**
+ * Merge the inbox owner and addressed actors into one row per actor.
+ * @returns The actor rows of an inbound log.
+ */
+export function inboundActorRows(entry: {
+ readonly actorId?: Uuid | null | undefined;
+ readonly addressed?: readonly AddressedActor[] | undefined;
+}): ActorRow[] {
+ const rows = new Map();
+ if (entry.actorId != null) {
+ rows.set(entry.actorId, { actorId: entry.actorId, inboxOwner: true });
+ }
+ for (const { actorId, viaCollectionIri } of entry.addressed ?? []) {
+ const row = rows.get(actorId);
+ rows.set(actorId, {
+ ...row,
+ actorId,
+ addressed: true,
+ viaCollectionIri:
+ row?.addressed === true && row.viaCollectionIri == null
+ ? null
+ : (viaCollectionIri ?? null),
+ });
+ }
+ return [...rows.values()];
+}
+
+/**
+ * PostgreSQL's jsonb holds neither U+0000 nor an unpaired surrogate, in keys
+ * or in values.
+ * @returns Whether the JSON value can be stored as jsonb.
+ */
+function storableJson(value: unknown): boolean {
+ if (typeof value === "string") {
+ return !value.includes("\0") && value.isWellFormed();
+ }
+ return (
+ typeof value !== "object" ||
+ value == null ||
+ Object.entries(value).every(
+ ([key, item]) => storableJson(key) && storableJson(item),
+ )
+ );
+}
+
+/**
+ * Keep a payload jsonb cannot hold out of the log, rather than the log.
+ * @returns The value to store, SQL `NULL` for such a payload.
+ */
+const jsonb = (payload: unknown) =>
+ payload === null
+ ? sql`'null'::jsonb`
+ : storableJson(payload)
+ ? payload
+ : null;
+
+/**
+ * PostgreSQL's text holds no U+0000, which a remote response or an error
+ * quoting one may carry; keep the rest of it rather than lose the log.
+ * @returns The text with each U+0000 replaced by U+FFFD.
+ */
+const text = (value: string | null | undefined): string | null =>
+ value?.replaceAll("\0", "\ufffd") ?? null;
+
+async function insertLog(
+ db: Database,
+ log: NewActivityLog,
+ actors: readonly ActorRow[],
+): Promise {
+ return await db.transaction(async (tx) => {
+ const [row] = await tx
+ .insert(activityLogs)
+ .values({
+ ...log,
+ error: text(log.error),
+ responseBody: text(log.responseBody),
+ })
+ .returning();
+ if (row == null) throw new Error("Missing activity log after insertion.");
+ if (actors.length > 0) {
+ await tx
+ .insert(activityLogActors)
+ .values(actors.map((actor) => ({ ...actor, logId: row.id })));
+ }
+ return row;
+ });
+}
+
+/**
+ * Persist one inbound observation with its actor rows in one transaction.
+ * @returns The inserted inbound log.
+ */
+export async function recordInbound(
+ db: Database,
+ { addressed, body, id = uuidV7(), ...entry }: InboundLogEntry,
+): Promise {
+ return await insertLog(
+ db,
+ {
+ ...entry,
+ body: Buffer.from(body),
+ payload: jsonb(entry.payload),
+ id,
+ direction: "inbound",
+ },
+ inboundActorRows({ actorId: entry.actorId, addressed }),
+ );
+}
+
+/**
+ * Record that an inbox listener ran for an inbound delivery answered before it
+ * did, as a queued one is.
+ * @returns Whether an acknowledged inbound log was found.
+ */
+export async function receiveInbound(db: Database, id: Uuid): Promise {
+ const rows = await db
+ .update(activityLogs)
+ .set({ status: "received" })
+ .where(
+ and(
+ eq(activityLogs.id, id),
+ eq(activityLogs.direction, "inbound"),
+ eq(activityLogs.status, "acknowledged"),
+ ),
+ )
+ .returning({ id: activityLogs.id });
+ return rows.length > 0;
+}
+
+/**
+ * Start a delivery observation. The payload is the document before signing.
+ * @returns The inserted queued outbound log.
+ */
+export async function recordOutbound(
+ db: Database,
+ entry: OutboundLogEntry,
+): Promise {
+ return await insertLog(
+ db,
+ {
+ ...entry,
+ // The application clock, as inbound logs use, so both directions sort together.
+ created: entry.created ?? Temporal.Now.instant(),
+ payload: jsonb(entry.payload),
+ id: uuidV7(),
+ direction: "outbound",
+ status: "queued",
+ },
+ entry.actorId == null ? [] : [{ actorId: entry.actorId, sender: true }],
+ );
+}
+
+/** What a settled delivery shows; null fields for a successful attempt. */
+export interface OutboundSettlement {
+ readonly activityIri: string;
+ readonly inboxUrl: string;
+ readonly status: "sent" | "failed" | "permanently_failed" | "abandoned";
+ readonly statusCode?: number | null;
+ readonly error?: string | null;
+ readonly responseBody?: string | null;
+ /** Whether an attempt ended with this result, rather than none being made. */
+ readonly attempted: boolean;
+ /** Restrict a synchronous delivery to the row started by that invocation. */
+ readonly id?: Uuid;
+}
+
+/**
+ * Settle the most recent pending (`queued` or `failed`) delivery to an inbox,
+ * keeping each ended attempt. `sent`, `permanently_failed` and `abandoned`
+ * deliveries are never changed again.
+ * @returns Whether a pending delivery was found.
+ */
+export async function settleOutbound(
+ db: Database,
+ entry: OutboundSettlement,
+): Promise {
+ const summary = {
+ statusCode: entry.statusCode ?? null,
+ error: text(entry.error),
+ responseBody: text(entry.responseBody),
+ };
+ return await db.transaction(async (tx) => {
+ const [log] = await tx
+ .select({ id: activityLogs.id })
+ .from(activityLogs)
+ .where(
+ and(
+ eq(activityLogs.direction, "outbound"),
+ eq(activityLogs.activityIri, entry.activityIri),
+ eq(activityLogs.inboxUrl, entry.inboxUrl),
+ inArray(activityLogs.status, ["queued", "failed"]),
+ entry.id == null ? undefined : eq(activityLogs.id, entry.id),
+ ),
+ )
+ .orderBy(desc(activityLogs.created), desc(activityLogs.id))
+ .limit(1)
+ .for("update");
+ if (log == null) return false;
+ const completed = Temporal.Now.instant();
+ if (entry.attempted) {
+ await tx.insert(activityLogAttempts).values({
+ ...summary,
+ id: uuidV7(),
+ logId: log.id,
+ succeeded: entry.status === "sent",
+ created: completed,
+ });
+ }
+ await tx
+ .update(activityLogs)
+ .set({ ...summary, status: entry.status, completed })
+ .where(eq(activityLogs.id, log.id));
+ return true;
+ });
+}
diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts
index a00025b..d5fb44b 100644
--- a/packages/models/src/index.ts
+++ b/packages/models/src/index.ts
@@ -20,3 +20,5 @@ export { relations } from "./relations.ts";
export * as schema from "./schema.ts";
export * from "./login.ts";
export * from "./resource.ts";
+export * from "./key.ts";
+export * from "./activity-log.ts";
diff --git a/packages/models/src/key.test.ts b/packages/models/src/key.test.ts
new file mode 100644
index 0000000..c4a3743
--- /dev/null
+++ b/packages/models/src/key.test.ts
@@ -0,0 +1,117 @@
+// 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 .
+
+// oxlint-disable id-length max-statements
+
+import assert from "node:assert/strict";
+import { it } from "node:test";
+
+import { migrate, relations, schema } from "@drfed/models";
+import {
+ jwkThumbprint,
+ observeKeyVersion,
+ toPublicJwk,
+} from "@drfed/models/key";
+import { uuidV7 } from "@drfed/models/uuid";
+import { PGlite } from "@electric-sql/pglite";
+import { drizzle } from "drizzle-orm/pglite";
+
+const ed = {
+ kty: "OKP",
+ crv: "Ed25519",
+ x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
+};
+
+it("matches RFC 7638 and RFC 8037 thumbprint vectors", async () => {
+ assert.equal(
+ await jwkThumbprint({
+ kty: "RSA",
+ e: "AQAB",
+ alg: "RS256",
+ kid: "2011-04-29",
+ n:
+ "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt" +
+ "VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6" +
+ "4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD" +
+ "W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9" +
+ "1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH" +
+ "aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
+ }),
+ "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
+ );
+ assert.equal(
+ await jwkThumbprint(ed),
+ "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k",
+ );
+ assert.equal(
+ await jwkThumbprint({ ...ed, ext: true, key_ops: ["verify"] }),
+ await jwkThumbprint(ed),
+ );
+ assert.deepEqual(toPublicJwk({ ...ed, ext: true, key_ops: ["verify"] }), ed);
+ assert.throws(() => toPublicJwk({ ...ed, d: "private" }), TypeError);
+ await assert.rejects(jwkThumbprint({ kty: "EC" }), TypeError);
+});
+
+it("reuses A after A, B, A without mutating material or firstSeen", async () => {
+ const client = new PGlite();
+ try {
+ await migrate({ credentials: { driver: "pglite", client } });
+ const db = drizzle({ client, schema, relations });
+ const iri = "https://remote.example/key";
+ const first = Temporal.Instant.from("2026-01-01T00:00:00Z");
+ const last = first.add({ hours: 2 });
+ const a = await observeKeyVersion(db, {
+ iri,
+ publicKey: ed,
+ observed: first,
+ });
+ const b = await observeKeyVersion(db, {
+ iri,
+ publicKey: { ...ed, x: "different" },
+ observed: first.add({ hours: 1 }),
+ });
+ const again = await observeKeyVersion(db, {
+ iri,
+ publicKey: { ...ed, kid: "ignored-change" },
+ observed: last,
+ });
+ assert.notEqual(a.id, b.id);
+ assert.equal(again.id, a.id);
+ assert.equal(again.firstSeen.toString(), first.toString());
+ assert.equal(again.lastSeen.toString(), last.toString());
+ assert.deepEqual(again.publicKey, ed);
+ assert.equal(await db.$count(schema.keys), 1);
+ assert.equal(await db.$count(schema.keyVersions), 2);
+ const earlier = await observeKeyVersion(db, {
+ iri,
+ publicKey: ed,
+ observed: first,
+ });
+ assert.equal(earlier.lastSeen.toString(), last.toString());
+ await assert.rejects(
+ db.insert(schema.keyVersions).values({
+ id: uuidV7(),
+ keyId: a.keyId,
+ fingerprint: "private",
+ publicKey: { ...ed, d: "private" },
+ firstSeen: first,
+ lastSeen: last,
+ }),
+ );
+ } finally {
+ await client.close();
+ }
+});
diff --git a/packages/models/src/key.ts b/packages/models/src/key.ts
new file mode 100644
index 0000000..d4dc83b
--- /dev/null
+++ b/packages/models/src/key.ts
@@ -0,0 +1,137 @@
+// 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 { eq, sql } from "drizzle-orm";
+
+import type { Database } from "./db.ts";
+import { type KeyVersion, keyVersions, keys } from "./schema.ts";
+import { uuidV7 } from "./uuid.ts";
+
+/** Web Crypto's JWK export type, including optional JOSE metadata. */
+export type PublicJwk = Exclude<
+ Awaited>,
+ ArrayBuffer
+> & { kid?: string; use?: string };
+
+const privateParameters = [
+ "d",
+ "p",
+ "q",
+ "dp",
+ "dq",
+ "qi",
+ "oth",
+ "k",
+] as const;
+
+/**
+ * Reject private/symmetric key material and remove mutable Web Crypto metadata.
+ * @returns The public JWK without key_ops and ext.
+ */
+export function toPublicJwk(jwk: PublicJwk): PublicJwk {
+ if (privateParameters.some((name) => name in jwk)) {
+ throw new TypeError("Expected an asymmetric public JWK.");
+ }
+ const { key_ops: _operations, ext: _extractable, ...publicKey } = jwk;
+ return publicKey;
+}
+
+/**
+ * RFC 7638 / RFC 8037 required members in lexicographic order.
+ * @returns Canonical JSON for the thumbprint hash.
+ */
+export function thumbprintInput(jwk: PublicJwk): string {
+ const key = toPublicJwk(jwk);
+ const required =
+ key.kty === "RSA"
+ ? // oxlint-disable id-length
+ { e: key.e, kty: key.kty, n: key.n }
+ : key.kty === "OKP" && key.crv === "Ed25519"
+ ? { crv: key.crv, kty: key.kty, x: key.x }
+ : null;
+ // oxlint-enable id-length
+ if (
+ required == null ||
+ Object.values(required).some(
+ (value) => typeof value !== "string" || value.length === 0,
+ )
+ ) {
+ throw new TypeError("Unsupported or incomplete public JWK.");
+ }
+ return JSON.stringify(required);
+}
+
+/**
+ * SHA-256 JWK thumbprint, base64url without padding.
+ * @returns The public key fingerprint.
+ */
+export async function jwkThumbprint(jwk: PublicJwk): Promise {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(thumbprintInput(jwk)),
+ );
+ return new Uint8Array(digest).toBase64({
+ alphabet: "base64url",
+ omitPadding: true,
+ });
+}
+
+/**
+ * Observe immutable public key material, reusing a version if a key returns to it.
+ * @returns The observed, immutable version with its updated lastSeen.
+ */
+export async function observeKeyVersion(
+ db: Database,
+ entry: {
+ readonly iri: string;
+ readonly publicKey: PublicJwk;
+ readonly observed?: Temporal.Instant;
+ },
+): Promise {
+ const publicKey = toPublicJwk(entry.publicKey);
+ const fingerprint = await jwkThumbprint(publicKey);
+ return await db.transaction(async (tx) => {
+ await tx
+ .insert(keys)
+ .values({ id: uuidV7(), iri: entry.iri })
+ .onConflictDoNothing({ target: keys.iri });
+ const [key] = await tx.select().from(keys).where(eq(keys.iri, entry.iri));
+ if (key == null) throw new Error("Missing logical key after insertion.");
+ // Take the observation time after serializing competing key insertions.
+ const observed = entry.observed ?? Temporal.Now.instant();
+ const [version] = await tx
+ .insert(keyVersions)
+ .values({
+ id: uuidV7(),
+ keyId: key.id,
+ publicKey,
+ fingerprint,
+ firstSeen: observed,
+ lastSeen: observed,
+ })
+ .onConflictDoUpdate({
+ target: [keyVersions.keyId, keyVersions.fingerprint],
+ set: {
+ lastSeen: sql`greatest(${keyVersions.lastSeen}, excluded.last_seen)`,
+ },
+ })
+ .returning();
+ if (version == null) {
+ throw new Error("Missing key version after insertion.");
+ }
+ return version;
+ });
+}
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index c57398d..04a586b 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -19,6 +19,63 @@ import { defineRelations } from "drizzle-orm";
import * as schema from "./schema.ts";
export const relations = defineRelations(schema, (r) => ({
+ keys: {
+ versions: r.many.keyVersions({ from: r.keys.id, to: r.keyVersions.keyId }),
+ },
+ keyVersions: {
+ key: r.one.keys({
+ from: r.keyVersions.keyId,
+ to: r.keys.id,
+ optional: false,
+ }),
+ activityLogs: r.many.activityLogs({
+ from: r.keyVersions.id,
+ to: r.activityLogs.verificationKeyId,
+ }),
+ },
+ activityLogs: {
+ instance: r.one.instances({
+ from: r.activityLogs.instanceId,
+ to: r.instances.id,
+ optional: false,
+ }),
+ actor: r.one.actors({
+ from: r.activityLogs.actorId,
+ to: r.actors.id,
+ where: { deleted: { isNull: true } },
+ }),
+ actorLinks: r.many.activityLogActors({
+ from: r.activityLogs.id,
+ to: r.activityLogActors.logId,
+ }),
+ verificationKey: r.one.keyVersions({
+ from: r.activityLogs.verificationKeyId,
+ to: r.keyVersions.id,
+ }),
+ attempts: r.many.activityLogAttempts({
+ from: r.activityLogs.id,
+ to: r.activityLogAttempts.logId,
+ }),
+ },
+ activityLogAttempts: {
+ log: r.one.activityLogs({
+ from: r.activityLogAttempts.logId,
+ to: r.activityLogs.id,
+ optional: false,
+ }),
+ },
+ activityLogActors: {
+ log: r.one.activityLogs({
+ from: r.activityLogActors.logId,
+ to: r.activityLogs.id,
+ optional: false,
+ }),
+ actor: r.one.actors({
+ from: r.activityLogActors.actorId,
+ to: r.actors.id,
+ optional: false,
+ }),
+ },
accounts: {
instances: r.many.instances({
from: r.accounts.id.through(r.instanceMembers.accountId),
@@ -56,6 +113,10 @@ export const relations = defineRelations(schema, (r) => ({
}),
},
instances: {
+ activityLogs: r.many.activityLogs({
+ from: r.instances.id,
+ to: r.activityLogs.instanceId,
+ }),
members: r.many.accounts({
from: r.instances.id.through(r.instanceMembers.instanceId),
to: r.accounts.id.through(r.instanceMembers.accountId),
@@ -220,6 +281,10 @@ export const relations = defineRelations(schema, (r) => ({
}),
},
actors: {
+ activityLogs: r.many.activityLogs({
+ from: r.actors.id.through(r.activityLogActors.actorId),
+ to: r.activityLogs.id.through(r.activityLogActors.logId),
+ }),
collectionReferences: r.many.actorCollectionReferences({
from: r.actors.id,
to: r.actorCollectionReferences.actorId,
diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts
index d7c1ba9..f7897f0 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -18,6 +18,7 @@ import { desc, sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
+ bytea,
char,
check,
customType,
@@ -34,6 +35,7 @@ import {
varchar,
} from "drizzle-orm/pg-core";
+import type { PublicJwk } from "./key.ts";
import type { Uuid } from "./uuid.ts";
/** A timestamptz column preserving PostgreSQL's microsecond precision. */
@@ -513,3 +515,267 @@ export const addressing = pgTable(
],
);
export type Addressing = typeof addressing.$inferSelect;
+
+/** Direction of an observed delivery. */
+export const activityLogDirectionEnum = pgEnum("activity_log_direction", [
+ "inbound",
+ "outbound",
+]);
+
+/**
+ * Inbound: `received` (2xx, listener ran), `acknowledged` (2xx, listener did
+ * not run), `unverified`, `rejected` (refused although verified); a request
+ * whose handling threw is one of the last two, without a status code. Outbound:
+ * `queued` (no attempt ended yet), `sent`, `failed` (the latest attempt
+ * failed; a queued delivery may be retried), `permanently_failed`,
+ * `abandoned` (the retry policy ran out).
+ */
+export const activityLogStatusEnum = pgEnum("activity_log_status", [
+ "received",
+ "acknowledged",
+ "unverified",
+ "rejected",
+ "queued",
+ "sent",
+ "failed",
+ "permanently_failed",
+ "abandoned",
+]);
+
+/** The mechanism that authenticated an inbound activity. */
+export const activityLogVerificationMechanismEnum = pgEnum(
+ "activity_log_verification_mechanism",
+ ["http_signature", "ld_signature", "object_integrity_proof"],
+);
+
+/**
+ * What Fedify reported of an inbound verification; `unattempted` when it
+ * answered before verifying, and `unobserved` when recording failed.
+ */
+export const activityLogVerificationResultEnum = pgEnum(
+ "activity_log_verification_result",
+ [
+ "verified",
+ "invalid_signature",
+ "key_fetch_error",
+ "no_signature",
+ "unattempted",
+ "unobserved",
+ ],
+);
+
+/** Logical public keys, identified by their exact IRI. */
+export const keys = pgTable("keys", {
+ id: uuid().$type().primaryKey(),
+ iri: text().notNull().unique(),
+ created: instant().notNull().default(currentTimestamp),
+});
+
+/** Immutable key material, independently retained from Fedify's KV cache. */
+export const keyVersions = pgTable(
+ "key_versions",
+ {
+ id: uuid().$type().primaryKey(),
+ keyId: uuid("key_id")
+ .$type()
+ .notNull()
+ .references(() => keys.id, { onDelete: "restrict" }),
+ publicKey: jsonb("public_key").$type().notNull(),
+ fingerprint: text().notNull(),
+ /** DrFed observation time, not remote rotation time or evidence of continuous use. */
+ firstSeen: instant("first_seen").notNull(),
+ /** DrFed observation time, not remote rotation time or evidence of continuous use. */
+ lastSeen: instant("last_seen").notNull(),
+ },
+ (table) => [
+ unique("key_versions_key_id_fingerprint_unique").on(
+ table.keyId,
+ table.fingerprint,
+ ),
+ check(
+ "key_versions_seen_check",
+ sql`${table.lastSeen} >= ${table.firstSeen}`,
+ ),
+ check(
+ "key_versions_public_key_check",
+ sql`NOT (${table.publicKey} ?| array['d','p','q','dp','dq','qi','oth','k'])`,
+ ),
+ index("key_version_key_first_seen_index").on(
+ table.keyId,
+ table.firstSeen,
+ table.id,
+ ),
+ ],
+);
+
+/** Delivery observations; payloads may contain unverified, private remote input. */
+export const activityLogs = pgTable(
+ "activity_logs",
+ {
+ id: uuid().$type().primaryKey(),
+ instanceId: uuid("instance_id")
+ .$type()
+ .notNull()
+ .references(() => instances.id, { onDelete: "cascade" }),
+ /** The owner of the inbox the request arrived at, or the sending actor. */
+ actorId: uuid("actor_id")
+ .$type()
+ .references(() => actors.id, { onDelete: "set null" }),
+ direction: activityLogDirectionEnum().notNull(),
+ status: activityLogStatusEnum().notNull(),
+ verificationMechanism: activityLogVerificationMechanismEnum(
+ "verification_mechanism",
+ ),
+ verificationResult: activityLogVerificationResultEnum(
+ "verification_result",
+ ),
+ type: text(),
+ types: text()
+ .array()
+ .notNull()
+ .default(sql`'{}'`),
+ activityIri: text("activity_iri"),
+ objectType: text("object_type"),
+ objectIri: text("object_iri"),
+ signedKeyIri: text("signed_key_iri"),
+ /** A referenced version does not imply successful verification. */
+ verificationKeyId: uuid("verification_key_id")
+ .$type()
+ .references(() => keyVersions.id, { onDelete: "restrict" }),
+ /** Inbound, only the first `actor`; the rest are in `payload`. */
+ remoteActorIri: text("remote_actor_iri"),
+ remoteHost: text("remote_host"),
+ inboxUrl: text("inbox_url").notNull(),
+ requestUrl: text("request_url"),
+ /** Names are lowercase; original order and case are not kept. */
+ headers: jsonb().$type(),
+ body: bytea(),
+ statusCode: integer("status_code"),
+ responseBody: text("response_body"),
+ error: text(),
+ payload: jsonb().$type(),
+ recipientIris: text("recipient_iris")
+ .array()
+ .notNull()
+ .default(sql`'{}'`),
+ /** Inbound, when the request arrived; outbound, when delivery started. */
+ created: instant().notNull().default(currentTimestamp),
+ /** Inbound, when DrFed answered; outbound, the latest status change. */
+ completed: instant(),
+ },
+ (table) => [
+ check(
+ "activity_logs_direction_status_check",
+ sql`(${table.direction} = 'inbound' AND ${table.status} IN ('received', 'acknowledged', 'unverified', 'rejected')) OR (${table.direction} = 'outbound' AND ${table.status} IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))`,
+ ),
+ check(
+ "activity_logs_completed_check",
+ sql`(${table.completed} IS NULL) = (${table.status} = 'queued')`,
+ ),
+ check(
+ "activity_logs_status_code_check",
+ sql`${table.statusCode} IS NULL OR ${table.statusCode} BETWEEN 100 AND 599`,
+ ),
+ check(
+ "activity_logs_outbound_key_check",
+ sql`${table.direction} <> 'outbound' OR ${table.verificationKeyId} IS NULL`,
+ ),
+ check(
+ "activity_logs_verification_result_check",
+ sql`(${table.direction} = 'inbound') = (${table.verificationResult} IS NOT NULL)`,
+ ),
+ check(
+ "activity_logs_verification_mechanism_check",
+ sql`${table.verificationMechanism} IS NULL OR (${table.direction} = 'inbound' AND ${table.verificationResult} NOT IN ('unattempted', 'unobserved'))`,
+ ),
+ check(
+ "activity_logs_body_check",
+ sql`(${table.direction} = 'inbound') = (${table.body} IS NOT NULL)`,
+ ),
+ index("activity_log_instance_created_index").on(
+ table.instanceId,
+ desc(table.created),
+ desc(table.id),
+ ),
+ index("activity_log_actor_index").on(table.actorId),
+ index("activity_log_verification_key_index").on(table.verificationKeyId),
+ index("activity_log_outbound_index")
+ .on(table.activityIri, table.inboxUrl)
+ .where(sql`${table.direction} = 'outbound'`),
+ ],
+);
+
+/** Each ended attempt of an outbound delivery. */
+export const activityLogAttempts = pgTable(
+ "activity_log_attempts",
+ {
+ id: uuid().$type().primaryKey(),
+ logId: uuid("log_id")
+ .$type()
+ .notNull()
+ .references(() => activityLogs.id, { onDelete: "cascade" }),
+ succeeded: boolean().notNull(),
+ statusCode: integer("status_code"),
+ responseBody: text("response_body"),
+ error: text(),
+ /** When the attempt ended. */
+ created: instant().notNull().default(currentTimestamp),
+ },
+ (table) => [
+ check(
+ "activity_log_attempts_status_code_check",
+ sql`${table.statusCode} IS NULL OR ${table.statusCode} BETWEEN 100 AND 599`,
+ ),
+ check(
+ "activity_log_attempts_error_check",
+ sql`${table.succeeded} = (${table.error} IS NULL)`,
+ ),
+ index("activity_log_attempt_log_index").on(
+ table.logId,
+ table.created,
+ table.id,
+ ),
+ ],
+);
+
+/** The local actors a log concerns, one row per log and actor. */
+export const activityLogActors = pgTable(
+ "activity_log_actors",
+ {
+ logId: uuid("log_id")
+ .$type()
+ .notNull()
+ .references(() => activityLogs.id, { onDelete: "cascade" }),
+ actorId: uuid("actor_id")
+ .$type()
+ .notNull()
+ .references(() => actors.id, { onDelete: "cascade" }),
+ inboxOwner: boolean("inbox_owner").notNull().default(false),
+ addressed: boolean().notNull().default(false),
+ sender: boolean().notNull().default(false),
+ viaCollectionIri: text("via_collection_iri"),
+ },
+ (table) => [
+ primaryKey({ columns: [table.logId, table.actorId] }),
+ check(
+ "activity_log_actors_role_check",
+ sql`${table.inboxOwner} OR ${table.addressed} OR ${table.sender}`,
+ ),
+ index("activity_log_actor_log_index").on(table.actorId, desc(table.logId)),
+ ],
+);
+export type Key = typeof keys.$inferSelect;
+export type KeyVersion = typeof keyVersions.$inferSelect;
+export type ActivityLog = typeof activityLogs.$inferSelect;
+export type NewActivityLog = typeof activityLogs.$inferInsert;
+export type ActivityLogAttempt = typeof activityLogAttempts.$inferSelect;
+export type ActivityLogActor = typeof activityLogActors.$inferSelect;
+export type NewActivityLogActor = typeof activityLogActors.$inferInsert;
+export type ActivityLogDirection =
+ (typeof activityLogDirectionEnum.enumValues)[number];
+export type ActivityLogStatus =
+ (typeof activityLogStatusEnum.enumValues)[number];
+export type ActivityLogVerificationMechanism =
+ (typeof activityLogVerificationMechanismEnum.enumValues)[number];
+export type ActivityLogVerificationResult =
+ (typeof activityLogVerificationResultEnum.enumValues)[number];
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2eea2ce..f310962 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,17 +13,17 @@ catalogs:
specifier: ^10.5.0
version: 10.5.0
'@fedify/fedify':
- specifier: 2.4.0-pr.1020.43+41cebe9c
- version: 2.4.0-pr.1020.43
+ specifier: 2.4.0
+ version: 2.4.0
'@fedify/pglite':
- specifier: 2.4.0-pr.1020.43+41cebe9c
- version: 2.4.0-pr.1020.43
+ specifier: 2.4.0
+ version: 2.4.0
'@fedify/postgres':
- specifier: 2.4.0-pr.1020.43+41cebe9c
- version: 2.4.0-pr.1020.43
+ specifier: 2.4.0
+ version: 2.4.0
'@fedify/vocab':
- specifier: 2.4.0-pr.1020.43+41cebe9c
- version: 2.4.0-pr.1020.43
+ specifier: 2.4.0
+ version: 2.4.0
'@logtape/drizzle-orm':
specifier: ^2.2.2
version: 2.2.2
@@ -115,13 +115,13 @@ importers:
version: 0.5.3
'@fedify/fedify':
specifier: 'catalog:'
- version: 2.4.0-pr.1020.43
+ version: 2.4.0
'@fedify/pglite':
specifier: 'catalog:'
- version: 2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43)
+ version: 2.4.0(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0)
'@fedify/postgres':
specifier: 'catalog:'
- version: 2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9)
+ version: 2.4.0(@fedify/fedify@2.4.0)(postgres@3.4.9)
'@logtape/drizzle-orm':
specifier: 'catalog:'
version: 2.2.2(@logtape/logtape@2.3.0-dev.840)
@@ -179,19 +179,22 @@ importers:
version: 10.5.0
'@fedify/fedify':
specifier: 'catalog:'
- version: 2.4.0-pr.1020.43
+ version: 2.4.0
'@fedify/uri-template':
specifier: ^2.3.1
version: 2.3.1
'@fedify/vocab':
specifier: 'catalog:'
- version: 2.4.0-pr.1020.43
+ version: 2.4.0
'@logtape/graphql-yoga':
specifier: 'catalog:'
version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2)
'@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)
@@ -298,10 +301,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))(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))
+ 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))
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(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))
+ 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))
relay-runtime:
specifier: ^21.0.1
version: 21.0.1
@@ -326,7 +329,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)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)
+ version: 0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)
relay-compiler:
specifier: ^21.0.1
version: 21.0.1
@@ -840,44 +843,44 @@ packages:
'@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
- '@fedify/fedify@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-uZvxgWklmBNZ3VQ6YJR7W+zZamwX8+2c1O4h/TZfjRm17ycVcFOHXNcdAFS2TjsVSQpo/axoGt25PI8Wb/+oQA==}
+ '@fedify/fedify@2.4.0':
+ resolution: {integrity: sha512-XlR202zMU5+tiISyU48cxe23IU9xXHVPunO+apSSgBgl922JnMNErM/l23HmHEMAtiFiRgiXG+AqC75ZxppWtw==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/pglite@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-R3meizlecC1EXHwiNgwCJ6LwtpCvqee46eK7HrCoxA/ll9+/goCgBY0QIYJdhS/EqknrYF1koSKoO8LxposnvQ==}
+ '@fedify/pglite@2.4.0':
+ resolution: {integrity: sha512-kTOfxuWQe9FrKz0LYsGOPallpWSOt/aXTOOivgcKidlbOYXT0JnKG8ftmjVNram2vq0cJGHYNev4pWUYX8pO2Q==}
peerDependencies:
'@electric-sql/pglite': ^0.5.8
- '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c
+ '@fedify/fedify': ^2.4.0
- '@fedify/postgres@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-EGhZXyRFf5oS4fdDdAVUxn1MkAbSxcV99AXZQ8PkH/ejpFNmgcHmsQabefpTl9wqEyg8l8t1M1C0EPvQ5N6t1Q==}
+ '@fedify/postgres@2.4.0':
+ resolution: {integrity: sha512-U4E0GKiEXvmpcNk6/97jGhzt+JxxjALlkH0+OPwgcQ6eatVSLKzvVyJ/8a2wRUHHIkgCFo7LO9tLZ+x7GGqxXg==}
peerDependencies:
- '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c
+ '@fedify/fedify': ^2.4.0
postgres: ^3.4.7
'@fedify/uri-template@2.3.1':
resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/uri-template@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-2JsU5q+pDNMuoD3ZcJLwubpZWgixwcy0H4yVY/bpz/vEEEWu+g22pa6r52rloWvn5ARJXAkMxcyHDcBlZojpBg==}
+ '@fedify/uri-template@2.4.0':
+ resolution: {integrity: sha512-PvYHcbqR/RKjU59RcdIqSWAVfXmpsJ1T+xTfk3zNxJWB/niB4T1MeFPQd5c4x4I6yZ2iQlqdvLCE8v3w0NLXIQ==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/vocab-runtime@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-97ctqwEghTbyCU24VuZ14yS9Fs+Wa2dhA9orVV/6hZgalaz8LRUv2mV5wgR2BKddfKmJG5m5i7icV3BkVVwD/w==}
+ '@fedify/vocab-runtime@2.4.0':
+ resolution: {integrity: sha512-UW5PCEUNsDFBt70OZCquprAbz5wl5s3GEaaDV8NbpReytAmSsY8GWiXJee2t5yqcIzJCmCgF4stJ/k9oX76jwA==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/vocab-tools@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-QL6t5PT/1sTNt8zG/TtggM7XJPKy3/Mem8l+unrt/rtCTTFtK5IO+VhnJz+PXfd61HEeZY680IYt71GSntgU8g==}
+ '@fedify/vocab-tools@2.4.0':
+ resolution: {integrity: sha512-7oKOSjfu3QGROwZwblNDL3vtAv4pnhuUPnbwGmvzfEoKPvzHztbXNAFI+cP7I4gtsSac1e/phudR2scoazPB5Q==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/vocab@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-fcvwaGXw5BJN28BIyDXQMSr4zS5F/ZWtTN3PFqVNtqGc5B5OdWLP+dwgq6CxAGW+vcC6t+8I84vSZbUML3Jq8A==}
+ '@fedify/vocab@2.4.0':
+ resolution: {integrity: sha512-3SRaSi+/Qp8CLR9MWE9qqQUrIZKhnGBoNSTmhvhxkopBk9nms94zYZXsFiQMpv9rdYz3xwJbs8mcI5yqvLX8vg==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
- '@fedify/webfinger@2.4.0-pr.1020.43':
- resolution: {integrity: sha512-tEWdomxujooqZzxXgd1UKGkny+DtObN2eYAFLrQfmy2M1hxEmuyphNNB/ECOWDM9QScDE2P72lJItCDuyWDOiw==}
+ '@fedify/webfinger@2.4.0':
+ resolution: {integrity: sha512-wXdY9nbnbNMzDcuSfymx6pddvE8Ikm+YbamKm/JA0il1Z3xdnuBEdAN4BgQ/aKnW8vhCyqw4jjXp9UZUNPMD6Q==}
engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'}
'@floating-ui/core@1.8.0':
@@ -3918,20 +3921,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
- '@babel/core@7.29.7(supports-color@7.2.0)':
+ '@babel/core@7.29.7':
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(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
- '@babel/traverse': 7.29.8(supports-color@7.2.0)
+ '@babel/traverse': 7.29.8
'@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3960,19 +3963,19 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
- '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
+ '@babel/helper-module-imports@7.29.7':
dependencies:
- '@babel/traverse': 7.29.8(supports-color@7.2.0)
+ '@babel/traverse': 7.29.8
'@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
- '@babel/core': 7.29.7(supports-color@7.2.0)
- '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
- '@babel/traverse': 7.29.8(supports-color@7.2.0)
+ '@babel/traverse': 7.29.8
transitivePeerDependencies:
- supports-color
@@ -3993,9 +3996,9 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
- '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
+ '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
dependencies:
- '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
@@ -4006,7 +4009,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
- '@babel/traverse@7.29.8(supports-color@7.2.0)':
+ '@babel/traverse@7.29.8':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
@@ -4014,7 +4017,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -4256,17 +4259,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)(supports-color@7.2.0))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))':
dependencies:
- eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2(supports-color@7.2.0)':
+ '@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4279,10 +4282,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5(supports-color@7.2.0)':
+ '@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.15.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -4306,12 +4309,12 @@ snapshots:
'@fastify/busboy@3.2.0': {}
- '@fedify/fedify@2.4.0-pr.1020.43':
+ '@fedify/fedify@2.4.0':
dependencies:
- '@fedify/uri-template': 2.4.0-pr.1020.43
- '@fedify/vocab': 2.4.0-pr.1020.43
- '@fedify/vocab-runtime': 2.4.0-pr.1020.43
- '@fedify/webfinger': 2.4.0-pr.1020.43
+ '@fedify/uri-template': 2.4.0
+ '@fedify/vocab': 2.4.0
+ '@fedify/vocab-runtime': 2.4.0
+ '@fedify/webfinger': 2.4.0
'@logtape/logtape': 2.3.2
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1)
@@ -4328,26 +4331,26 @@ snapshots:
temporal-polyfill: 1.0.4
urlpattern-polyfill: 10.1.0
- '@fedify/pglite@2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43)':
+ '@fedify/pglite@2.4.0(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0)':
dependencies:
'@electric-sql/pglite': 0.5.3
- '@fedify/fedify': 2.4.0-pr.1020.43
+ '@fedify/fedify': 2.4.0
'@logtape/logtape': 2.3.2
'@types/emscripten': 1.41.6
temporal-polyfill: 1.0.4
- '@fedify/postgres@2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9)':
+ '@fedify/postgres@2.4.0(@fedify/fedify@2.4.0)(postgres@3.4.9)':
dependencies:
- '@fedify/fedify': 2.4.0-pr.1020.43
+ '@fedify/fedify': 2.4.0
'@logtape/logtape': 2.3.2
postgres: 3.4.9
temporal-polyfill: 1.0.4
'@fedify/uri-template@2.3.1': {}
- '@fedify/uri-template@2.4.0-pr.1020.43': {}
+ '@fedify/uri-template@2.4.0': {}
- '@fedify/vocab-runtime@2.4.0-pr.1020.43':
+ '@fedify/vocab-runtime@2.4.0':
dependencies:
'@js-temporal/polyfill': 0.5.1
'@logtape/logtape': 2.3.2
@@ -4358,18 +4361,18 @@ snapshots:
jsonld: 9.0.0
pkijs: 3.4.0
- '@fedify/vocab-tools@2.4.0-pr.1020.43':
+ '@fedify/vocab-tools@2.4.0':
dependencies:
'@cfworker/json-schema': 4.1.1
byte-encodings: 1.0.11
es-toolkit: 1.46.1
yaml: 2.9.0
- '@fedify/vocab@2.4.0-pr.1020.43':
+ '@fedify/vocab@2.4.0':
dependencies:
- '@fedify/vocab-runtime': 2.4.0-pr.1020.43
- '@fedify/vocab-tools': 2.4.0-pr.1020.43
- '@fedify/webfinger': 2.4.0-pr.1020.43
+ '@fedify/vocab-runtime': 2.4.0
+ '@fedify/vocab-tools': 2.4.0
+ '@fedify/webfinger': 2.4.0
'@logtape/logtape': 2.3.2
'@multiformats/base-x': 4.0.1
'@opentelemetry/api': 1.9.1
@@ -4379,9 +4382,9 @@ snapshots:
pkijs: 3.4.0
temporal-polyfill: 1.0.4
- '@fedify/webfinger@2.4.0-pr.1020.43':
+ '@fedify/webfinger@2.4.0':
dependencies:
- '@fedify/vocab-runtime': 2.4.0-pr.1020.43
+ '@fedify/vocab-runtime': 2.4.0
'@logtape/logtape': 2.3.2
'@opentelemetry/api': 1.9.1
es-toolkit: 1.46.1
@@ -5046,10 +5049,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))(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))':
+ '@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))':
dependencies:
- '@babel/core': 7.29.7(supports-color@7.2.0)
- '@babel/traverse': 7.29.8(supports-color@7.2.0)
+ '@babel/core': 7.29.7
+ '@babel/traverse': 7.29.8
'@babel/types': 7.29.8
'@solidjs/meta': 0.29.4(solid-js@1.9.14)
'@types/babel__traverse': 7.28.0
@@ -5073,7 +5076,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)(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))
+ 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))
optionalDependencies:
'@solidjs/router': 1.0.0(solid-js@1.9.14)
transitivePeerDependencies:
@@ -5148,11 +5151,11 @@ snapshots:
'@types/unist@3.0.3': {}
- '@typescript-eslint/project-service@8.62.1(supports-color@7.2.0)(typescript@7.0.2)':
+ '@typescript-eslint/project-service@8.62.1(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(supports-color@7.2.0)
+ debug: 4.4.3
typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -5168,13 +5171,13 @@ snapshots:
'@typescript-eslint/types@8.62.1': {}
- '@typescript-eslint/typescript-estree@8.62.1(supports-color@7.2.0)(typescript@7.0.2)':
+ '@typescript-eslint/typescript-estree@8.62.1(typescript@7.0.2)':
dependencies:
- '@typescript-eslint/project-service': 8.62.1(supports-color@7.2.0)(typescript@7.0.2)
+ '@typescript-eslint/project-service': 8.62.1(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(supports-color@7.2.0)
+ debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
@@ -5183,13 +5186,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@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)':
+ '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.62.1
'@typescript-eslint/types': 8.62.1
- '@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-eslint/typescript-estree': 8.62.1(typescript@7.0.2)
+ eslint: 9.39.4(jiti@2.7.0)
typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -5410,19 +5413,19 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
- babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@7.2.0)):
+ babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7):
dependencies:
- '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/core': 7.29.7
'@babel/helper-module-imports': 7.18.6
- '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
'@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(supports-color@7.2.0))(solid-js@1.9.14):
+ babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14):
dependencies:
- '@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))
+ '@babel/core': 7.29.7
+ babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7)
optionalDependencies:
solid-js: 1.9.14
@@ -5546,11 +5549,9 @@ 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(supports-color@7.2.0):
+ debug@4.4.3:
dependencies:
ms: 2.1.3
- optionalDependencies:
- supports-color: 7.2.0
deep-is@0.1.4: {}
@@ -5679,10 +5680,10 @@ snapshots:
escape-string-regexp@4.0.0: {}
- 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):
+ eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2):
dependencies:
- '@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)
+ '@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)
estraverse: 5.3.0
is-html: 2.0.0
kebab-case: 1.0.2
@@ -5703,14 +5704,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0):
+ eslint@9.39.4(jiti@2.7.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2(supports-color@7.2.0)
+ '@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5(supports-color@7.2.0)
+ '@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -5720,7 +5721,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -5959,11 +5960,11 @@ snapshots:
dependencies:
loose-envify: 1.4.0
- ioredis@5.11.1(supports-color@7.2.0):
+ ioredis@5.11.1:
dependencies:
'@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
@@ -6181,7 +6182,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(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)):
+ 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)):
dependencies:
consola: 3.4.2
crossws: 0.4.10(srvx@0.11.16)
@@ -6196,7 +6197,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(supports-color@7.2.0))(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)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3)
optionalDependencies:
dotenv: 17.4.2
giget: 3.3.0
@@ -6637,10 +6638,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)(supports-color@7.2.0):
+ solid-refresh@0.6.3(solid-js@1.9.14):
dependencies:
'@babel/generator': 7.29.8
- '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
+ '@babel/helper-module-imports': 7.29.7
'@babel/types': 7.29.8
solid-js: 1.9.14
transitivePeerDependencies:
@@ -6851,11 +6852,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(supports-color@7.2.0))(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)(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(supports-color@7.2.0)
+ ioredis: 5.11.1
lru-cache: 11.5.1
ofetch: 2.0.0-alpha.3
@@ -6907,14 +6908,14 @@ snapshots:
transitivePeerDependencies:
- typescript
- 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)):
+ 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)):
dependencies:
- '@babel/core': 7.29.7(supports-color@7.2.0)
+ '@babel/core': 7.29.7
'@types/babel__core': 7.20.5
- babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14)
+ babel-preset-solid: 1.9.12(@babel/core@7.29.7)(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)(supports-color@7.2.0)
+ solid-refresh: 0.6.3(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)
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/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3ce0a1d..2b31fdf 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -9,10 +9,10 @@ allowBuilds:
catalog:
"@electric-sql/pglite": ^0.5.3
"@faker-js/faker": ^10.5.0
- "@fedify/fedify": 2.4.0-pr.1020.43+41cebe9c
- "@fedify/postgres": 2.4.0-pr.1020.43+41cebe9c
- "@fedify/pglite": 2.4.0-pr.1020.43+41cebe9c
- "@fedify/vocab": 2.4.0-pr.1020.43+41cebe9c
+ "@fedify/fedify": 2.4.0
+ "@fedify/postgres": 2.4.0
+ "@fedify/pglite": 2.4.0
+ "@fedify/vocab": 2.4.0
"@logtape/drizzle-orm": ^2.2.2
"@logtape/graphql-yoga": 2.3.0-dev.840
"@logtape/logtape": 2.3.0-dev.840
@@ -35,14 +35,14 @@ catalog:
uuid: ^14.0.1
minimumReleaseAgeExclude:
- - "@fedify/fedify@2.4.0-pr.1020.43"
- - "@fedify/postgres@2.4.0-pr.1020.43"
- - "@fedify/uri-template@2.4.0-pr.1020.43"
- - "@fedify/vocab-runtime@2.4.0-pr.1020.43"
- - "@fedify/vocab-tools@2.4.0-pr.1020.43"
- - "@fedify/vocab@2.4.0-pr.1020.43"
- - "@fedify/webfinger@2.4.0-pr.1020.43"
- - "@fedify/pglite@2.4.0-pr.1020.43"
+ - "@fedify/fedify@2.4.0"
+ - "@fedify/postgres@2.4.0"
+ - "@fedify/uri-template@2.4.0"
+ - "@fedify/vocab-runtime@2.4.0"
+ - "@fedify/vocab-tools@2.4.0"
+ - "@fedify/vocab@2.4.0"
+ - "@fedify/webfinger@2.4.0"
+ - "@fedify/pglite@2.4.0"
- "@logtape/graphql-yoga@2.3.0-dev.840"
- "@logtape/logtape@2.3.0-dev.840"
- "@logtape/testing-node@2.3.0-dev.840"