From 5ac8d778c5a7513cae659d536aa13f31989a8e3d Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Mon, 28 Sep 2026 21:53:57 +0900
Subject: [PATCH 01/13] Add activity delivery logs and verification key history
Implement the activity-log API from plans/12: immutable public-key versions,
delivery observations, inbox recording, synchronous outbound hooks, and
member-authorized Relay connections. Preserve original inbound JSON and
keep key history independent of the federation cache.
The contributor requested implementation of the supplied plan and an
independent Claude Fable 5 review outside the sandbox. Codex generated the
schema, migration, recording helpers, GraphQL API, integration, tests, and
documentation. Implementation checks led to nullable GraphQL JSON payloads,
request-local verification-key snapshots, and an empty key dispatcher until
local signing keys are implemented. Fable identified orphan key observations
on unclaimed hosts; Codex moved the instance gate before verification and
added regression coverage. Fable's second review found no issues.
Agent validation: mise run build, mise run check, and all 240 tests passed.
A running development server accepted a signed inbox request (202), rejected
an unsigned request (401), and persisted the expected logs and key version.
Tarball installation with locally installed external dependencies verified
new exports, bundled migrations, and drfed-server --help. Human review and
verification are not asserted by these agent-run checks.
Assisted-by: Codex:gpt-6-astra
Assisted-by: Claude Code:claude-fable-5
---
.gitignore | 1 +
.oxfmtrc.json | 3 +-
CONTRIBUTING.md | 9 +
packages/drfed/src/index.ts | 10 +-
packages/drfed/src/serving.test.ts | 55 +
packages/drfed/src/serving.ts | 4 +-
packages/graphql/README.md | 30 +-
packages/graphql/package.json | 7 +-
packages/graphql/src/activity-log.test.ts | 693 ++++
packages/graphql/src/activity-log.ts | 183 +
packages/graphql/src/activity-log/describe.ts | 75 +
packages/graphql/src/activity-log/inbound.ts | 232 ++
packages/graphql/src/activity-log/keycache.ts | 112 +
packages/graphql/src/activity-log/outbound.ts | 174 +
packages/graphql/src/federation.ts | 24 +-
packages/graphql/src/schema.ts | 1 +
packages/models/README.md | 15 +
.../migration.sql | 52 +
.../snapshot.json | 2938 +++++++++++++++++
packages/models/package.json | 12 +-
packages/models/src/activity-log.test.ts | 141 +
packages/models/src/activity-log.ts | 115 +
packages/models/src/index.ts | 2 +
packages/models/src/key.test.ts | 115 +
packages/models/src/key.ts | 131 +
packages/models/src/relations.ts | 34 +
packages/models/src/schema.ts | 125 +
packages/web/schema.graphql | 764 +++++
28 files changed, 6047 insertions(+), 10 deletions(-)
create mode 100644 packages/graphql/src/activity-log.test.ts
create mode 100644 packages/graphql/src/activity-log.ts
create mode 100644 packages/graphql/src/activity-log/describe.ts
create mode 100644 packages/graphql/src/activity-log/inbound.ts
create mode 100644 packages/graphql/src/activity-log/keycache.ts
create mode 100644 packages/graphql/src/activity-log/outbound.ts
create mode 100644 packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
create mode 100644 packages/models/drizzle/20260928123149_add_activity_logs_and_keys/snapshot.json
create mode 100644 packages/models/src/activity-log.test.ts
create mode 100644 packages/models/src/activity-log.ts
create mode 100644 packages/models/src/key.test.ts
create mode 100644 packages/models/src/key.ts
create mode 100644 packages/web/schema.graphql
diff --git a/.gitignore b/.gitignore
index 3cdfb96..bc1d46f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.DS_Store
.env
*.graphql
+!packages/web/schema.graphql
*.graphql.ts
__generated__/
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 0c1ebc8..f4883f5 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -9,6 +9,7 @@
".agents/skills/",
".claude/skills/",
"plans/",
- "**/__generated__/**"
+ "**/__generated__/**",
+ "packages/web/schema.graphql"
]
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1ee30f7..f7ff936 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -362,6 +362,15 @@ 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 the same KV store as Fedify. Keep the
+public-key cache serialization compatible with the installed Fedify version.
+Use `deliverActivity` for future outgoing delivery; queue-backed delivery is
+not supported until delivery success callbacks are available. Log payloads
+are private to local instance members and administrators, including Relay node
+lookups.
+
CLI and server changes
----------------------
diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index 6d6a572..9df75f9 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";
@@ -60,7 +62,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,
+ kv,
+ }),
rootOrigin,
serveControlSurface: yogaServer.fetch,
}),
diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts
index fea5552..6cfa953 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, kv }),
+ 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..af466dc 100644
--- a/packages/drfed/src/serving.ts
+++ b/packages/drfed/src/serving.ts
@@ -19,7 +19,7 @@ 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 federation surface (including the inbound recorder) that the router needs, narrowed so that
* the routing can be exercised without building one.
*/
export interface FederationHandler {
@@ -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..45f2522 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, kv });
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,28 @@ 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. Payloads may contain unverified input and private recipients.
+A literal JSON `null` body is retained and exposed as a nullable `payload`.
+
+`ActivityLog.verificationKey` retains the observed public key version even
+when verification failed. `KeyVersion.firstSeen` and `lastSeen` are DrFed
+observation times, not remote rotation times or evidence of continuous use.
+
+Wrap the federation HTTP surface with `createInboundRecorder` using the same
+KV store (and public-key prefix when customized). JSON inbox POSTs are logged;
+non-JSON bodies are excluded. Logging errors never replace federation responses.
+
+`deliverActivity` is the outbound entry point for explicit recipients once
+local actor keys are available (#87). It records one row per destination inbox
+and settles each synchronous delivery independently. `createFederation` rejects
+queues until Fedify exposes delivery success callbacks. Activity resource
+persistence (#88), retention policies, and the activity-log UI (#13) are
+separate.
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index d1b6fcd..6b7c47d 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.d.mts",
+ "default": "./dist/activity-log.mjs"
}
},
"files": [
@@ -98,7 +102,8 @@
"src/instance.ts",
"src/schema.ts",
"src/object.ts",
- "src/origin.ts"
+ "src/origin.ts",
+ "src/activity-log.ts"
],
"dts": {
"sourcemap": true,
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
new file mode 100644
index 0000000..082875a
--- /dev/null
+++ b/packages/graphql/src/activity-log.test.ts
@@ -0,0 +1,693 @@
+// 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
+import assert from "node:assert/strict";
+import { it } from "node:test";
+
+import {
+ classifyInbound,
+ createInboundRecorder,
+ createKeyCache,
+ createOutboxErrorHandler,
+ createPermanentFailureHandler,
+ deliverActivity,
+ describeActivity,
+} from "@drfed/graphql/activity-log";
+import createFederation 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,
+ type Federation,
+ MemoryKvStore,
+ SendActivityError,
+ generateCryptoKeyPair,
+ signRequest,
+} from "@fedify/fedify";
+import {
+ Create,
+ CryptographicKey,
+ type DocumentLoader,
+ Multikey,
+ Person,
+} from "@fedify/vocab";
+import { eq } from "drizzle-orm";
+
+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 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;
+ let loads = 0;
+ const documentLoader: DocumentLoader = async (url) => {
+ loads += 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, kv });
+ 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.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);
+ const firstLoads = loads;
+ assert.equal(firstLoads, 1);
+ assert.equal((await send(payload("second"))).status, 202);
+ assert.equal(loads, firstLoads);
+ 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", 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.ok(bad?.verificationKeyId);
+ const mismatch = {
+ ...payload("mismatch"),
+ actor: "https://other.example/actor",
+ };
+ assert.equal((await send(mismatch, b.privateKey)).status, 401);
+ assert.equal(
+ (
+ await db.query.activityLogs.findFirst({
+ where: { activityIri: mismatch.id },
+ })
+ )?.status,
+ "rejected",
+ );
+ 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", keyId.href]);
+ assert.equal(await db.$count(schema.keyVersions), 2);
+ });
+});
+
+it("records missing signatures and failed key fetches, and skips non-JSON/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, kv });
+ 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);
+ 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.match(failed?.error ?? "", /keyFetchError/u);
+ await recorder.fetch(
+ new Request(inbox, { method: "POST", body: "not JSON" }),
+ fetchOptions,
+ );
+ 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), 2);
+ await recorder.fetch(request(null), fetchOptions);
+ const nullPayload = await db.query.activityLogs.findFirst({
+ where: { type: { isNull: true } },
+ });
+ assert.equal(nullPayload?.payload, null);
+ await db.execute(`DROP TABLE activity_logs`);
+ 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", 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", 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", 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,
+ activityIri: null,
+ remoteActorIri: null,
+ objectType: null,
+ objectIri: null,
+ });
+ assert.equal(
+ (await describeActivity({ type: "Extension", id: "urn:test" })).type,
+ "Extension",
+ );
+ assert.equal(
+ classifyInbound({ verified: false, reason: { type: "noSignature" } }, 202),
+ "received",
+ );
+ assert.deepEqual(
+ await describeActivity({
+ "@context": 123,
+ type: "Extension",
+ id: "urn:test",
+ actor: ["unknown"],
+ object: "urn:object",
+ }),
+ {
+ type: "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, {
+ 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 { 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, {
+ 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,
+ );
+ }
+ });
+});
+
+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() });
+ const ctx = federation.createContext(new URL(inbox), undefined);
+ const recipient = {
+ id: actorIri,
+ inboxId: new URL("https://remote.example/inbox"),
+ };
+ const activity = new Create({
+ id: new URL("https://test-instance.drfed.org/activity/1"),
+ actor: ctx.getActorUri(localActorId),
+ });
+ const sender = { identifier: localActorId };
+ const fakeContext = (
+ sendActivity: Context["sendActivity"],
+ ): Context =>
+ new Proxy(ctx, {
+ get(target, property) {
+ return property === "sendActivity"
+ ? sendActivity
+ : Reflect.get(target, property);
+ },
+ });
+ await deliverActivity(
+ db,
+ fakeContext(() => Promise.resolve()),
+ sender,
+ [recipient, recipient],
+ activity,
+ );
+ assert.equal(await db.$count(schema.activityLogs), 1);
+ assert.equal((await db.query.activityLogs.findFirst())?.status, "sent");
+ 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 deliverActivity(
+ db,
+ fakeContext(async () => {
+ await createOutboxErrorHandler(db)(failure, failActivity);
+ await createPermanentFailureHandler(db)(ctx, {
+ activity: failActivity,
+ inbox: recipient.inboxId,
+ error: failure,
+ statusCode: 410,
+ reason: "http",
+ actorIds: [actorIri],
+ });
+ }),
+ sender,
+ recipient,
+ failActivity,
+ );
+ const failed = await db.query.activityLogs.findFirst({
+ where: { activityIri: failActivity.id!.href },
+ });
+ assert.equal(failed?.status, "permanently_failed");
+ assert.equal(failed?.statusCode, 410);
+ assert.equal(failed?.error, "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() });
+ const context = federation.createContext(new URL(inbox), undefined);
+ const good = {
+ id: actorIri,
+ inboxId: new URL("https://remote.example/good"),
+ };
+ 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 async (_sender: unknown, recipients: { inboxId: URL }[]) => {
+ if (recipients[0]?.inboxId.href === bad.inboxId.href) {
+ const error = new SendActivityError(
+ bad.inboxId,
+ 503,
+ "unavailable",
+ "Try later",
+ );
+ await createOutboxErrorHandler(db)(error, activity);
+ throw error;
+ }
+ };
+ },
+ });
+ 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, "failed");
+ assert.equal(rows[0]?.statusCode, 503);
+ assert.equal(rows[0]?.error, "Try later");
+ assert.equal(rows[1]?.status, "sent");
+ });
+});
+
+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, {
+ 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 Federation;
+ const recorder = createInboundRecorder({ db, federation, kv });
+ 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.ts b/packages/graphql/src/activity-log.ts
new file mode 100644
index 0000000..51ad03c
--- /dev/null
+++ b/packages/graphql/src/activity-log.ts
@@ -0,0 +1,183 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import {
+ activityLogDirectionEnum,
+ activityLogStatusEnum,
+} 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 "./activity-log/keycache.ts";
+export {
+ classifyInbound,
+ createInboundRecorder,
+} from "./activity-log/inbound.ts";
+export { describeActivity } from "./activity-log/describe.ts";
+export {
+ deliverActivity,
+ createOutboxErrorHandler,
+ createPermanentFailureHandler,
+} from "./activity-log/outbound.ts";
+
+const ActivityLogDirection = builder.enumType("ActivityLogDirection", {
+ values: activityLogDirectionEnum.enumValues,
+});
+const ActivityLogStatus = builder.enumType("ActivityLogStatus", {
+ values: activityLogStatusEnum.enumValues,
+});
+const ActivityLogFilter = builder.inputType("ActivityLogFilter", {
+ fields: (t) => ({
+ direction: t.field({ type: ActivityLogDirection }),
+ status: t.field({ type: ActivityLogStatus }),
+ type: t.string(),
+ }),
+});
+const access = (localId: Uuid | null) =>
+ localId == null
+ ? false
+ : { $any: { admin: true as const, localInstanceMember: localId } };
+
+const KeyRef = builder.drizzleNode("keys", {
+ name: "Key",
+ 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.relation("versions", {
+ 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",
+ 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: observationDescription,
+ }),
+ lastSeen: t.expose("lastSeen", {
+ type: "DateTime",
+ description: observationDescription,
+ }),
+ }),
+});
+export const KeyVersion: DrFedObjectRef = KeyVersionRef;
+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 }),
+ direction: t.expose("direction", { type: ActivityLogDirection }),
+ status: t.expose("status", { type: ActivityLogStatus }),
+ type: t.exposeString("type", { nullable: true }),
+ 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 }),
+ verificationKey: t.relation("verificationKey", {
+ nullable: true,
+ description:
+ "The public key version used in verification, even when it failed. Its presence does not imply success; consult status.",
+ }),
+ remoteActorIri: t.expose("remoteActorIri", { type: "URL", nullable: true }),
+ remoteHost: t.exposeString("remoteHost", { nullable: true }),
+ inboxUrl: t.expose("inboxUrl", { type: "URL" }),
+ statusCode: t.exposeInt("statusCode", { nullable: true }),
+ error: t.exposeString("error", { nullable: true }),
+ payload: t.expose("payload", {
+ type: "JSON",
+ nullable: true,
+ description:
+ "Original inbound JSON or compact outbound JSON-LD. May contain unverified remote input and private recipients. Null represents a literal JSON null body.",
+ }),
+ created: t.expose("created", { type: "DateTime" }),
+ }),
+});
+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:
+ "This local actor's delivery observations, newest first. 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/describe.ts b/packages/graphql/src/activity-log/describe.ts
new file mode 100644
index 0000000..ac1c778
--- /dev/null
+++ b/packages/graphql/src/activity-log/describe.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 .
+
+import { Object as APObject, Activity } from "@fedify/vocab";
+
+function document(value: unknown): Record {
+ return value != null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+const string = (value: unknown): string | null =>
+ typeof value === "string" ? value : null;
+
+/**
+ * Describe JSON-LD without dereferencing its actor or object.
+ * @returns Best-effort activity metadata.
+ */
+export async function describeActivity(
+ payload: unknown,
+ options: Parameters[1] = {},
+) {
+ let value = document(payload);
+ 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);
+ if (!(activity instanceof Activity)) {
+ throw new TypeError("Expected an ActivityStreams activity.");
+ }
+ value = document(
+ await activity.toJsonLd({ ...options, format: "compact" }),
+ );
+ } catch {
+ return {
+ type: string(value.type),
+ activityIri: string(value.id),
+ remoteActorIri: string(value.actor),
+ objectType: null,
+ objectIri: null,
+ };
+ }
+ const object = document(value.object);
+ return {
+ type: string(value.type),
+ activityIri: string(value.id),
+ remoteActorIri: string(value.actor) ?? string(document(value.actor).id),
+ objectType: string(object.type),
+ objectIri: string(value.object) ?? string(object.id),
+ };
+}
+
+/**
+ * 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 iri = actorIri ?? keyIri;
+ return iri != null && URL.canParse(iri) ? new URL(iri).host || 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..ad9cba5
--- /dev/null
+++ b/packages/graphql/src/activity-log/inbound.ts
@@ -0,0 +1,232 @@
+// 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 { observeKeyVersion } from "@drfed/models/key";
+import { type Uuid, validateUuid } from "@drfed/models/uuid";
+import {
+ type Federation,
+ type FederationFetchOptions,
+ type KeyCache,
+ type KvKey,
+ type KvStore,
+ type RequestContext,
+ type VerifyRequestDetailedResult,
+ exportJwk,
+ verifyRequestDetailed,
+} from "@fedify/fedify";
+import { getLogger } from "@logtape/logtape";
+
+import { canonicalizeAuthority } from "../origin.ts";
+import { describeActivity, remoteHost } from "./describe.ts";
+import { createKeyCache } from "./keycache.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+/**
+ * The actual federation response determines whether an activity was accepted.
+ * @returns The inbound delivery status.
+ */
+export function classifyInbound(
+ verification: VerifyRequestDetailedResult,
+ responseStatus: number,
+): "received" | "unverified" | "rejected" {
+ if (responseStatus >= 200 && responseStatus < 300) return "received";
+ return verification.verified ? "rejected" : "unverified";
+}
+
+function signedKeyId(
+ verification: VerifyRequestDetailedResult | undefined,
+): URL | null {
+ if (verification == null) return null;
+ if (verification.verified) return verification.key.id;
+ return verification.reason.type === "noSignature"
+ ? null
+ : (verification.reason.keyId ?? null);
+}
+
+function verificationError(result: VerifyRequestDetailedResult): string | null {
+ if (result.verified) return null;
+ const { reason } = result;
+ if (reason.type !== "keyFetchError") return reason.type;
+ return `keyFetchError: ${"status" in reason.result ? reason.result.status : reason.result.error.name}`;
+}
+
+type Loaders = Pick<
+ RequestContext,
+ "documentLoader" | "contextLoader"
+>;
+
+async function observeVerification(
+ db: Database,
+ request: Request,
+ keyCache: KeyCache,
+ loaders: Loaders,
+) {
+ const observedKeys = new Map>>();
+ const observedCache: KeyCache = {
+ ...keyCache,
+ async get(id) {
+ const key = await keyCache.get(id);
+ observedKeys.set(id.href, key);
+ return key;
+ },
+ async set(id, key) {
+ observedKeys.set(id.href, key);
+ await keyCache.set(id, key);
+ },
+ };
+ let verification: VerifyRequestDetailedResult | undefined;
+ let verificationKeyId: Uuid | null = null;
+ // Capture key material before Fedify or another request can refresh the cache.
+ try {
+ verification = await verifyRequestDetailed(request, {
+ keyCache: observedCache,
+ ...loaders,
+ });
+ const key = verification.verified
+ ? verification.key
+ : verification.reason.type === "invalidSignature" &&
+ verification.reason.keyId != null
+ ? observedKeys.get(verification.reason.keyId.href)
+ : null;
+ const keyId = verification.verified
+ ? verification.key.id
+ : verification.reason.type === "noSignature"
+ ? null
+ : verification.reason.keyId;
+ if (key?.publicKey != null && keyId != null) {
+ verificationKeyId = (
+ await observeKeyVersion(db, {
+ iri: keyId.href,
+ publicKey: await exportJwk(key.publicKey),
+ })
+ ).id;
+ }
+ } catch (error) {
+ logger.error("Could not observe inbox verification: {error}", {
+ error,
+ });
+ }
+ return { verification, verificationKeyId };
+}
+
+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;
+ }
+}
+
+/**
+ * Wrap inbox POSTs while preserving Fedify responses even when recording fails.
+ * @returns A fetch handler that records inbox observations.
+ */
+export function createInboundRecorder({
+ db,
+ federation,
+ kv,
+ publicKeyPrefix,
+}: {
+ readonly db: Database;
+ readonly federation: Federation;
+ readonly kv: KvStore;
+ readonly publicKeyPrefix?: KvKey;
+}): {
+ fetch(
+ request: Request,
+ options: FederationFetchOptions,
+ ): Promise;
+} {
+ return {
+ async fetch(request, options) {
+ const ctx = federation.createContext(request, options.contextData);
+ const route = ctx.parseUri(new URL(request.url));
+ if (request.method !== "POST" || 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);
+ if (instance == null) return await federation.fetch(request, options);
+ let payload: unknown;
+ try {
+ payload = await request.clone().json();
+ } catch {
+ return await federation.fetch(request, options);
+ }
+ const loaders = {
+ documentLoader: ctx.documentLoader,
+ contextLoader: ctx.contextLoader,
+ };
+ const keyCache = createKeyCache(kv, publicKeyPrefix, loaders);
+ const { verification, verificationKeyId } = await observeVerification(
+ db,
+ request,
+ keyCache,
+ loaders,
+ );
+ const response = await federation.fetch(request, options);
+ try {
+ const actor =
+ route.identifier != null && validateUuid(route.identifier)
+ ? await db.query.actors.findFirst({
+ where: {
+ id: route.identifier,
+ instanceId: instance.id,
+ localId: { isNotNull: true },
+ },
+ })
+ : null;
+ const signedKeyIri = signedKeyId(verification)?.href ?? null;
+ const description = await describeActivity(payload, loaders);
+ await recordInbound(db, {
+ ...description,
+ instanceId: instance.id,
+ actorId: actor?.id ?? null,
+ status:
+ verification == null
+ ? response.ok
+ ? "received"
+ : "unverified"
+ : classifyInbound(verification, response.status),
+ signedKeyIri,
+ verificationKeyId,
+ remoteHost: remoteHost(description.remoteActorIri, signedKeyIri),
+ inboxUrl: request.url,
+ statusCode: response.status,
+ error:
+ verification == null
+ ? "Verification observation failed"
+ : verificationError(verification),
+ payload,
+ });
+ } catch (error) {
+ logger.error("Could not record inbox activity: {error}", { error });
+ }
+ return response;
+ },
+ };
+}
diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts
new file mode 100644
index 0000000..65505d9
--- /dev/null
+++ b/packages/graphql/src/activity-log/keycache.ts
@@ -0,0 +1,112 @@
+// 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";
+
+type Loaders = Parameters[1];
+type DiagnosticKeyCache = KeyCache & {
+ getFetchError(keyId: URL): Promise;
+ setFetchError(
+ keyId: URL,
+ error: FetchKeyErrorResult | undefined,
+ ): Promise;
+};
+const unavailableTtl = Temporal.Duration.from({ minutes: 10 });
+
+/**
+ * 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 errorKey = (id: URL): KvKey => [...prefix, "__fetchError", id.href];
+ return {
+ async get(keyId) {
+ const value = await kv.get([...prefix, keyId.href]);
+ if (value == null) return value;
+ try {
+ return await CryptographicKey.fromJsonLd(value, options);
+ } catch {
+ try {
+ return await Multikey.fromJsonLd(value, options);
+ } catch {
+ await kv.delete([...prefix, keyId.href]);
+ return undefined;
+ }
+ }
+ },
+ async set(keyId, key) {
+ await kv.set(
+ [...prefix, keyId.href],
+ key == null ? null : await key.toJsonLd(options),
+ key == null ? { ttl: unavailableTtl } : undefined,
+ );
+ },
+ 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 });
+ },
+ };
+}
diff --git a/packages/graphql/src/activity-log/outbound.ts b/packages/graphql/src/activity-log/outbound.ts
new file mode 100644
index 0000000..bce4d82
--- /dev/null
+++ b/packages/graphql/src/activity-log/outbound.ts
@@ -0,0 +1,174 @@
+// 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,
+ type OutboxErrorHandler,
+ type OutboxPermanentFailureHandler,
+ SendActivityError,
+} from "@fedify/fedify";
+import type { Activity, Recipient } from "@fedify/vocab";
+import { getLogger } from "@logtape/logtape";
+
+import { canonicalizeAuthority } from "../origin.ts";
+import { describeActivity, remoteHost } from "./describe.ts";
+
+const logger = getLogger(["drfed", "graphql", "activity-log"]);
+
+/**
+ * Record HTTP failures without making diagnostic persistence affect delivery.
+ * @returns A Fedify outbox error callback.
+ */
+export function createOutboxErrorHandler(db: Database): OutboxErrorHandler {
+ return async (error, activity) => {
+ if (!(error instanceof SendActivityError) || activity?.id == null) return;
+ try {
+ await settleOutbound(db, {
+ activityIri: activity.id.href,
+ inboxUrl: error.inbox.href,
+ status: "failed",
+ statusCode: error.statusCode,
+ error: error.responseBody,
+ });
+ } catch (cause) {
+ logger.error("Could not record delivery failure: {error}", {
+ error: cause,
+ });
+ }
+ };
+}
+
+/**
+ * Preserve permanent failures even if the general error callback follows them.
+ * @returns A Fedify permanent-failure callback.
+ */
+export function createPermanentFailureHandler(
+ db: Database,
+): OutboxPermanentFailureHandler {
+ return async (_ctx, values) => {
+ if (values.activity.id == null) return;
+ try {
+ await settleOutbound(db, {
+ activityIri: values.activity.id.href,
+ inboxUrl: values.inbox.href,
+ status: "permanently_failed",
+ statusCode: values.statusCode,
+ error: values.error.responseBody,
+ });
+ } catch (error) {
+ logger.error("Could not record permanent delivery failure: {error}", {
+ error,
+ });
+ }
+ };
+}
+
+/**
+ * Deliver to explicit recipients through the current synchronous federation.
+ * Each activity needs a unique IRI. Queue-backed delivery is not supported.
+ * 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 targets = new Map();
+ for (const recipient of Array.isArray(recipients)
+ ? recipients
+ : [recipients]) {
+ if (recipient.inboxId == null) continue;
+ const url = recipient.inboxId.href;
+ targets.set(url, [...(targets.get(url) ?? []), recipient]);
+ }
+ const results = await Promise.allSettled(
+ Array.from(targets, async ([inboxUrl, group]) => {
+ let row: ActivityLog | undefined;
+ try {
+ const payload = await activity.toJsonLd({
+ format: "compact",
+ contextLoader: ctx.contextLoader,
+ });
+ const description = await describeActivity(payload, {
+ contextLoader: ctx.contextLoader,
+ });
+ const recipient = group[0]!;
+ row = await recordOutbound(db, {
+ ...description,
+ instanceId: actor.instanceId,
+ actorId: actor.id,
+ activityIri: activity.id!.href,
+ remoteActorIri: recipient.id?.href ?? null,
+ remoteHost: remoteHost(recipient.id?.href ?? null, inboxUrl),
+ inboxUrl,
+ payload,
+ });
+ } catch (error) {
+ logger.error("Could not record outgoing activity: {error}", { error });
+ }
+ const settle = async (status: "sent" | "failed", error?: unknown) => {
+ if (row == null) return;
+ try {
+ await settleOutbound(db, {
+ id: row.id,
+ activityIri: activity.id!.href,
+ inboxUrl,
+ status,
+ onlyQueued: true,
+ error: error == null ? null : String(error),
+ });
+ } catch (cause) {
+ logger.error("Could not settle outgoing activity: {error}", {
+ error: cause,
+ });
+ }
+ };
+ try {
+ // Resolve each destination independently: one failing inbox must not mark
+ // a successful delivery as failed or leave it queued.
+ await ctx.sendActivity(sender, group, activity);
+ } catch (error) {
+ await settle("failed", error);
+ throw error;
+ }
+ await settle("sent");
+ }),
+ );
+ const failure = results.find((result) => result.status === "rejected");
+ if (failure?.status === "rejected") throw failure.reason;
+}
diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts
index 94a2e15..d45087e 100644
--- a/packages/graphql/src/federation.ts
+++ b/packages/graphql/src/federation.ts
@@ -51,8 +51,15 @@ import {
import { getLogger } from "@logtape/logtape";
import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm";
+import {
+ createOutboxErrorHandler,
+ createPermanentFailureHandler,
+} from "./activity-log/outbound.ts";
import { canonicalizeAuthority } from "./origin.ts";
+export { createInboundRecorder } from "./activity-log/inbound.ts";
+export { deliverActivity } from "./activity-log/outbound.ts";
+
/**
* The vocabulary object types that DrFed serves as actors.
*/
@@ -123,6 +130,8 @@ export function buildFederation(db: Database): FederationBuilder {
}
return toActorObject(ctx, identifier, actor);
})
+ // Until #87 supplies keys, inbox loaders use unsigned document fetching.
+ .setKeyPairsDispatcher(() => [])
.mapHandle(async (ctx, username) => {
const actor = await db.query.actors.findFirst({
where: {
@@ -312,6 +321,7 @@ export function buildFederation(db: Database): FederationBuilder {
};
},
);
+ builder.setOutboxPermanentFailureHandler(createPermanentFailureHandler(db));
return builder;
}
@@ -328,7 +338,19 @@ export default async function createFederation(
db: Database,
options: FederationOptions,
): Promise> {
- return await buildFederation(db).build(options);
+ if (options.queue != null) {
+ throw new TypeError(
+ "Activity logging requires synchronous delivery; queues are not supported.",
+ );
+ }
+ const recordError = createOutboxErrorHandler(db);
+ return await buildFederation(db).build({
+ ...options,
+ async onOutboxError(error, activity) {
+ await recordError(error, activity);
+ await options.onOutboxError?.(error, activity);
+ },
+ });
}
// Whether a sanction is *currently* active is always determined by comparing
diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts
index 7dc7463..3912397 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -19,6 +19,7 @@ import "./instance.ts";
import "./auth/entry.ts";
import "./actor.ts";
import "./object.ts";
+import "./activity-log.ts";
import builder from "./builder.ts";
builder.queryType({});
diff --git a/packages/models/README.md b/packages/models/README.md
index 645949a..c7daba4 100644
--- a/packages/models/README.md
+++ b/packages/models/README.md
@@ -28,3 +28,18 @@ 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`,
+`recordOutbound`, and `settleOutbound`.
+
+`@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/20260928123149_add_activity_logs_and_keys/migration.sql b/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
new file mode 100644
index 0000000..7a1c109
--- /dev/null
+++ b/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
@@ -0,0 +1,52 @@
+CREATE TYPE "activity_log_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint
+CREATE TYPE "activity_log_status" AS ENUM('received', 'unverified', 'rejected', 'queued', 'sent', 'failed', 'permanently_failed');--> 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,
+ "type" text,
+ "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,
+ "status_code" integer,
+ "error" text,
+ "payload" jsonb NOT NULL,
+ "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ CONSTRAINT "activity_logs_direction_status_check" CHECK (("direction" = 'inbound' AND "status" IN ('received', 'unverified', 'rejected')) OR ("direction" = 'outbound' AND "status" IN ('queued', 'sent', 'failed', 'permanently_failed'))),
+ 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)
+);
+--> 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_instance_created_index" ON "activity_logs" ("instance_id","created" desc,"id" desc);--> statement-breakpoint
+CREATE INDEX "activity_log_actor_created_index" ON "activity_logs" ("actor_id","created" desc,"id" desc);--> 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
+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/20260928123149_add_activity_logs_and_keys/snapshot.json b/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/snapshot.json
new file mode 100644
index 0000000..d95c1cf
--- /dev/null
+++ b/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/snapshot.json
@@ -0,0 +1,2938 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "78cf5984-6dea-4b2c-b5c6-20591c77a1c4",
+ "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",
+ "unverified",
+ "rejected",
+ "queued",
+ "sent",
+ "failed",
+ "permanently_failed"
+ ],
+ "name": "activity_log_status",
+ "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_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": "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": "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": 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": "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": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_logs"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "payload",
+ "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": "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": "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
+ },
+ {
+ "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_actor_created_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": "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": ["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": ["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_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": "(\"direction\" = 'inbound' AND \"status\" IN ('received', 'unverified', 'rejected')) OR (\"direction\" = 'outbound' AND \"status\" IN ('queued', 'sent', 'failed', 'permanently_failed'))",
+ "name": "activity_logs_direction_status_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": "\"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..ccbef35
--- /dev/null
+++ b/packages/models/src/activity-log.test.ts
@@ -0,0 +1,141 @@
+// 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 assert from "node:assert/strict";
+import { it } from "node:test";
+
+import { migrate, relations, schema } from "@drfed/models";
+import {
+ 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 { eq } 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 inbound = await recordInbound(db, {
+ ...entry,
+ status: "unverified",
+ verificationKeyId: version.id,
+ });
+ assert.deepEqual(inbound.payload, entry.payload);
+ 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({
+ ...entry,
+ id: uuidV7(),
+ direction: "inbound",
+ 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({
+ ...entry,
+ id: uuidV7(),
+ direction: "inbound",
+ status: "received",
+ statusCode: 600,
+ }),
+ );
+ const outgoing = {
+ ...entry,
+ activityIri: "https://local.example/activity/1",
+ };
+ const row = await recordOutbound(db, outgoing);
+ const current = () =>
+ db.query.activityLogs.findFirst({ where: { id: row.id } });
+ assert.equal((await current())?.status, "queued");
+ await settleOutbound(db, {
+ ...outgoing,
+ status: "failed",
+ statusCode: 503,
+ error: "Unavailable",
+ });
+ await settleOutbound(db, { ...outgoing, status: "sent" });
+ assert.equal((await current())?.status, "failed");
+ await settleOutbound(db, {
+ ...outgoing,
+ status: "permanently_failed",
+ statusCode: 410,
+ });
+ await settleOutbound(db, {
+ ...outgoing,
+ status: "failed",
+ statusCode: 503,
+ });
+ assert.equal((await current())?.statusCode, 410);
+ const second = await recordOutbound(db, {
+ ...outgoing,
+ activityIri: "https://local.example/activity/2",
+ });
+ await settleOutbound(db, {
+ ...outgoing,
+ activityIri: second.activityIri!,
+ status: "sent",
+ });
+ assert.equal(
+ (await db.query.activityLogs.findFirst({ where: { id: second.id } }))
+ ?.status,
+ "sent",
+ );
+ await db
+ .delete(schema.instances)
+ .where(eq(schema.instances.id, instanceId));
+ assert.equal(await db.$count(schema.activityLogs), 0);
+ await db
+ .delete(schema.keyVersions)
+ .where(eq(schema.keyVersions.id, version.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..bd2aff1
--- /dev/null
+++ b/packages/models/src/activity-log.ts
@@ -0,0 +1,115 @@
+// 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, eq, inArray, sql } from "drizzle-orm";
+
+import type { Database } from "./db.ts";
+import {
+ type ActivityLog,
+ type NewActivityLog,
+ activityLogs,
+} from "./schema.ts";
+import { type Uuid, uuidV7 } from "./uuid.ts";
+
+type LogEntry = Omit;
+export type InboundLogEntry = LogEntry & {
+ readonly status: "received" | "unverified" | "rejected";
+};
+export type OutboundLogEntry = Omit & {
+ readonly activityIri: string;
+};
+
+/**
+ * Persist one inbound observation without reserializing its original payload.
+ * @returns The inserted inbound log.
+ */
+export async function recordInbound(
+ db: Database,
+ entry: InboundLogEntry,
+): Promise {
+ const [row] = await db
+ .insert(activityLogs)
+ .values({
+ ...entry,
+ payload: entry.payload === null ? sql`'null'::jsonb` : entry.payload,
+ id: uuidV7(),
+ direction: "inbound",
+ })
+ .returning();
+ if (row == null) throw new Error("Missing inbound log after insertion.");
+ return row;
+}
+
+/**
+ * Start a delivery observation. Outbound keys are not verification keys.
+ * @returns The inserted queued outbound log.
+ */
+export async function recordOutbound(
+ db: Database,
+ entry: OutboundLogEntry,
+): Promise {
+ const [row] = await db
+ .insert(activityLogs)
+ .values({
+ ...entry,
+ payload: entry.payload === null ? sql`'null'::jsonb` : entry.payload,
+ id: uuidV7(),
+ direction: "outbound",
+ status: "queued",
+ verificationKeyId: null,
+ })
+ .returning();
+ if (row == null) throw new Error("Missing outbound log after insertion.");
+ return row;
+}
+
+/** Settle pending deliveries; successful or permanent results are never overwritten. */
+export async function settleOutbound(
+ db: Database,
+ entry: {
+ readonly activityIri: string;
+ readonly inboxUrl: string;
+ readonly status: "sent" | "failed" | "permanently_failed";
+ readonly statusCode?: number | null;
+ readonly error?: string | null;
+ /** Restrict a synchronous completion to the row started by that invocation. */
+ readonly id?: Uuid;
+ /** Leave detailed failure callbacks intact when settling a thrown exception. */
+ readonly onlyQueued?: boolean;
+ },
+): Promise {
+ await db
+ .update(activityLogs)
+ .set({
+ status: entry.status,
+ statusCode: entry.statusCode ?? null,
+ error: entry.error ?? null,
+ })
+ .where(
+ and(
+ eq(activityLogs.direction, "outbound"),
+ eq(activityLogs.activityIri, entry.activityIri),
+ eq(activityLogs.inboxUrl, entry.inboxUrl),
+ entry.id == null ? undefined : eq(activityLogs.id, entry.id),
+ inArray(
+ activityLogs.status,
+ entry.status === "sent" || entry.onlyQueued
+ ? ["queued"]
+ : ["queued", "failed"],
+ ),
+ ),
+ );
+}
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..23e4225
--- /dev/null
+++ b/packages/models/src/key.test.ts
@@ -0,0 +1,115 @@
+// 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 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..6454aaf
--- /dev/null
+++ b/packages/models/src/key.ts
@@ -0,0 +1,131 @@
+// 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 { webcrypto } from "node:crypto";
+
+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";
+
+/** Public JWK fields including optional JOSE metadata. */
+export type PublicJwk = webcrypto.JsonWebKey & { 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): webcrypto.JsonWebKey {
+ 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"
+ ? { 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;
+ 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 Buffer.from(digest).toString("base64url");
+}
+
+/**
+ * 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..9913fb3 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -19,6 +19,32 @@ 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 }),
+ verificationKey: r.one.keyVersions({
+ from: r.activityLogs.verificationKeyId,
+ to: r.keyVersions.id,
+ }),
+ },
accounts: {
instances: r.many.instances({
from: r.accounts.id.through(r.instanceMembers.accountId),
@@ -56,6 +82,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 +250,10 @@ export const relations = defineRelations(schema, (r) => ({
}),
},
actors: {
+ activityLogs: r.many.activityLogs({
+ from: r.actors.id,
+ to: r.activityLogs.actorId,
+ }),
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..73746a6 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -14,6 +14,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import type { webcrypto } from "node:crypto";
+
import { desc, sql } from "drizzle-orm";
import {
type AnyPgColumn,
@@ -513,3 +515,126 @@ export const addressing = pgTable(
],
);
export type Addressing = typeof addressing.$inferSelect;
+
+/** Direction of an observed delivery. */
+export const activityLogDirectionEnum = pgEnum("activity_log_direction", [
+ "inbound",
+ "outbound",
+]);
+export const activityLogStatusEnum = pgEnum("activity_log_status", [
+ "received",
+ "unverified",
+ "rejected",
+ "queued",
+ "sent",
+ "failed",
+ "permanently_failed",
+]);
+
+/** 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'])`,
+ ),
+ ],
+);
+
+/** 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" }),
+ actorId: uuid("actor_id")
+ .$type()
+ .references(() => actors.id, { onDelete: "set null" }),
+ direction: activityLogDirectionEnum().notNull(),
+ status: activityLogStatusEnum().notNull(),
+ type: text(),
+ 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; consult status. */
+ verificationKeyId: uuid("verification_key_id")
+ .$type()
+ .references(() => keyVersions.id, { onDelete: "restrict" }),
+ remoteActorIri: text("remote_actor_iri"),
+ remoteHost: text("remote_host"),
+ inboxUrl: text("inbox_url").notNull(),
+ statusCode: integer("status_code"),
+ error: text(),
+ payload: jsonb().$type().notNull(),
+ created: instant().notNull().default(currentTimestamp),
+ },
+ (table) => [
+ check(
+ "activity_logs_direction_status_check",
+ sql`(${table.direction} = 'inbound' AND ${table.status} IN ('received', 'unverified', 'rejected')) OR (${table.direction} = 'outbound' AND ${table.status} IN ('queued', 'sent', 'failed', 'permanently_failed'))`,
+ ),
+ 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`,
+ ),
+ index("activity_log_instance_created_index").on(
+ table.instanceId,
+ desc(table.created),
+ desc(table.id),
+ ),
+ index("activity_log_actor_created_index").on(
+ table.actorId,
+ desc(table.created),
+ desc(table.id),
+ ),
+ index("activity_log_verification_key_index").on(table.verificationKeyId),
+ index("activity_log_outbound_index")
+ .on(table.activityIri, table.inboxUrl)
+ .where(sql`${table.direction} = 'outbound'`),
+ ],
+);
+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 ActivityLogDirection =
+ (typeof activityLogDirectionEnum.enumValues)[number];
+export type ActivityLogStatus =
+ (typeof activityLogStatusEnum.enumValues)[number];
diff --git a/packages/web/schema.graphql b/packages/web/schema.graphql
new file mode 100644
index 0000000..acb1e23
--- /dev/null
+++ b/packages/web/schema.graphql
@@ -0,0 +1,764 @@
+"""
+Represents an `Account` in the DrFed platform. Note that it differs from the ActivityPub `Actor`s that belong to `Instance`s.
+"""
+type Account implements Node {
+ """
+ Whether the `Account` has administrator privileges. `null` unless the viewer is the `Account` itself or a site administrator.
+ """
+ admin: Boolean
+
+ """The date/time when the `Account` was created."""
+ created: DateTime!
+
+ """
+ The email address of the `Account`. `null` unless the viewer is the `Account` itself or a site administrator.
+ """
+ email: Email
+
+ """The unique identifier of the `Account`."""
+ id: ID!
+
+ """
+ The `Instance`s that the `Account` belongs to. `null` unless the viewer is the `Account` itself or a site administrator; an empty connection means the `Account` belongs to no `Instance`.
+ """
+ instances(after: String, before: String, first: Int, last: Int): AccountInstancesConnection
+
+ """The display name of the `Account`."""
+ name: String!
+
+ """The UUID of the `Account`."""
+ uuid: UUID!
+}
+
+type AccountInstancesConnection {
+ edges: [AccountInstancesConnectionEdge!]!
+ pageInfo: PageInfo!
+
+ """
+ The total number of `Instance`s that the `Account` belongs to.Note that pending memberships are not counted.
+ """
+ totalCount: Int!
+}
+
+type AccountInstancesConnectionEdge {
+ """
+ The date/time when the `Account` accepted membership in the `Instance`.
+ """
+ accepted: DateTime
+
+ """Whether the `Account` has administrator privileges in the `Instance`."""
+ admin: Boolean!
+
+ """The date/time when the `Account` was added to the `Instance`."""
+ created: DateTime!
+ cursor: String!
+ node: Instance!
+}
+
+type Activity implements Node {
+ actor: Actor!
+
+ """Stored audience occurrences, in original order including duplicates."""
+ audience: [Resource!]!
+
+ """Stored bcc occurrences, in original order including duplicates."""
+ bcc: [Resource!]!
+
+ """Stored bto occurrences, in original order including duplicates."""
+ bto: [Resource!]!
+
+ """Stored cc occurrences, in original order including duplicates."""
+ cc: [Resource!]!
+ document: JSON
+
+ """
+ Expected classifications for this activity and its Object. Empty if the object is absent, hidden, or not an Object.
+ """
+ expectedClassifications: [ExpectedClassification!]!
+ id: ID!
+ iri: URL!
+ object: Resource
+ published: DateTime!
+ resource: Resource!
+
+ """Stored to occurrences, in original order including duplicates."""
+ to: [Resource!]!
+ type: ActivityType!
+}
+
+type ActivityLog implements Node {
+ activityIri: URL
+ actor: Actor
+ created: DateTime!
+ direction: ActivityLogDirection!
+ error: String
+ id: ID!
+ inboxUrl: URL!
+ instance: Instance!
+ objectIri: URL
+ objectType: String
+
+ """
+ Original inbound JSON or compact outbound JSON-LD. May contain unverified remote input and private recipients. Null represents a literal JSON null body.
+ """
+ payload: JSON
+ remoteActorIri: URL
+ remoteHost: String
+ signedKeyIri: URL
+ status: ActivityLogStatus!
+ statusCode: Int
+ type: String
+ uuid: UUID!
+
+ """
+ The public key version used in verification, even when it failed. Its presence does not imply success; consult status.
+ """
+ verificationKey: KeyVersion
+}
+
+enum ActivityLogDirection {
+ inbound
+ outbound
+}
+
+input ActivityLogFilter {
+ direction: ActivityLogDirection
+ status: ActivityLogStatus
+ type: String
+}
+
+enum ActivityLogStatus {
+ failed
+ permanently_failed
+ queued
+ received
+ rejected
+ sent
+ unverified
+}
+
+enum ActivityType {
+ Create
+}
+
+"""Represents an `Actor` in the DrFed platform."""
+type Actor implements Node {
+ """
+ This local actor's delivery observations, newest first. Restricted to instance members and administrators.
+ """
+ activityLogs(after: String, before: String, filter: ActivityLogFilter, first: Int, last: Int): ActorActivityLogsConnection!
+
+ """The avatar URL of the `Actor`."""
+ avatarUrl: URL
+
+ """The creation date/time of the `Actor`."""
+ created: DateTime!
+ featured: Collection
+ followers: Collection
+ following: Collection
+
+ """The handle of the `Actor`."""
+ handle: String!
+
+ """The header URL of the `Actor`."""
+ headerUrl: URL
+
+ """The unique identifier of the `Actor`."""
+ id: ID!
+
+ """The inbox URL of the `Actor`."""
+ inboxUrl: URL!
+
+ """The `Instance` that the `Actor` belongs to."""
+ instance: Instance!
+ iri: URL!
+
+ """The local details of the `Actor`, or null if it is remote."""
+ local: LocalActor
+
+ """
+ Non-deleted objects, newest publication first. All addressing is publicly readable through GraphQL.
+ """
+ objects(after: String, before: String, first: Int, last: Int): ObjectConnection!
+
+ """
+ The stored outbox collection. For local actors, it contains every stored `Create` activity regardless of addressing and retains activities whose objects are deleted so their recorded history remains inspectable. The ActivityPub outbox serves only activities with Public addressing whose objects are not deleted.
+ """
+ outbox: Collection
+
+ """The profile URL of the `Actor`."""
+ profileUrl: URL
+ resource: Resource!
+
+ """
+ The type of the `Actor`: `Application` | `Group` | `Organization` | `Person` | `Service`
+ """
+ type: ActorType!
+
+ """The username of the `Actor`."""
+ username: String!
+
+ """The UUID of the `Actor`."""
+ uuid: UUID!
+}
+
+type ActorActivityLogsConnection {
+ edges: [ActorActivityLogsConnectionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ActorActivityLogsConnectionEdge {
+ cursor: String!
+ node: ActivityLog!
+}
+
+enum ActorType {
+ Application
+ Group
+ Organization
+ Person
+ Service
+}
+
+input AddressingInput {
+ audience: [URL!]! = []
+ bcc: [URL!]! = []
+ bto: [URL!]! = []
+ cc: [URL!]! = []
+ to: [URL!]! = []
+}
+
+type Collection implements Node {
+ """
+ The `totalItems` reported by the collection document. It can differ from the locally observed count and is null for local collections. Unlike `totalCount` and `items`, this value is not recalculated when deleted actors, deleted objects, or resources authored by deleted actors are excluded.
+ """
+ declaredTotalItems: Int
+ id: ID!
+ iri: URL!
+
+ """
+ The locally stored, visible members. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this connection and `totalCount`.
+ """
+ items(after: String, before: String, first: Int, last: Int): CollectionItemsConnection!
+ owner: Actor
+ resource: Resource!
+
+ """
+ The number of locally stored, visible members. It can differ from `declaredTotalItems`. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this count and `items`.
+ """
+ totalCount: Int!
+ type: CollectionType!
+}
+
+type CollectionItemsConnection {
+ edges: [CollectionItemsConnectionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type CollectionItemsConnectionEdge {
+ cursor: String!
+ node: Resource!
+}
+
+enum CollectionType {
+ Collection
+ OrderedCollection
+}
+
+"""Represents an error that occurred while creating an `Actor`."""
+type CreateActorsError {
+ """
+ A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
+ """
+ message: String!
+
+ """The type of the error. Use this for programmatic error handling."""
+ type: CreateActorsErrorType!
+}
+
+enum CreateActorsErrorType {
+ InstanceNotFound
+ InvalidSize
+ TooManyActors
+}
+
+union CreateActorsResult = CreateActorsError | CreateActorsSuccess
+
+type CreateActorsSuccess {
+ actors: [Actor!]!
+}
+
+"""Represents an error that occurred while creating an `Instance`."""
+type CreateInstanceError {
+ """
+ A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
+ """
+ message: String!
+
+ """The type of the error. Use this for programmatic error handling."""
+ type: CreateInstanceErrorType!
+}
+
+enum CreateInstanceErrorType {
+ InvalidSlug
+ SlugAlreadyTaken
+ TooManyInstances
+}
+
+union CreateInstanceResult = CreateInstanceError | Instance
+
+type CreateObjectError {
+ """
+ A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
+ """
+ message: String!
+
+ """The type of the error. Use this for programmatic error handling."""
+ type: CreateObjectErrorType!
+}
+
+enum CreateObjectErrorType {
+ ActorNotFound
+ InvalidContent
+ InvalidLanguage
+}
+
+union CreateObjectResult = CreateObjectError | Object
+
+"""An ISO 8601 instant preserving sub-millisecond precision."""
+scalar DateTime
+
+scalar Email
+
+"""
+Expected classification; actual access depends on receiver state and policy.
+"""
+type ExpectedClassification {
+ classification: String!
+ implementation: Implementation!
+ reason: String!
+ version: String!
+}
+
+enum Implementation {
+ MASTODON
+ MISSKEY
+}
+
+"""Represents an `Instance` in the DrFed platform."""
+type Instance implements Node {
+ """
+ Delivery observations, newest first. Restricted to local instance members and administrators.
+ """
+ activityLogs(after: String, before: String, filter: ActivityLogFilter, first: Int, last: Int): InstanceActivityLogsConnection!
+
+ """The `Actor`s that belong to the `Instance`."""
+ actors(after: String, before: String, first: Int, last: Int): InstanceActorsConnection!
+
+ """The creation date/time of the `Instance`."""
+ created: DateTime!
+ host: String!
+
+ """The unique identifier of the `Instance`."""
+ id: ID!
+
+ """
+ The `LocalInstance` backing the `Instance` when it is hosted by this DrFed deployment. `null` if the `Instance` is remote, i.e., hosted by another server on the fediverse.
+ """
+ localInstance: LocalInstance
+
+ """The `Account`s that belong to the `Instance`."""
+ members(after: String, before: String, first: Int, last: Int): InstanceMembersConnection!
+ nodeInfoUrl: String
+ software: String
+ softwareVersion: String
+
+ """
+ The absolute origin the `Instance` is served at, e.g. `https://foo-bar.drfed.net`. Local instances follow this deployment's root origin, so a development deployment yields an `http:` URL carrying its port; remote instances are always `https:`.
+ """
+ url: String!
+ uuid: UUID!
+}
+
+type InstanceActivityLogsConnection {
+ edges: [InstanceActivityLogsConnectionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type InstanceActivityLogsConnectionEdge {
+ cursor: String!
+ node: ActivityLog!
+}
+
+type InstanceActorsConnection {
+ edges: [InstanceActorsConnectionEdge!]!
+ pageInfo: PageInfo!
+
+ """The total number of `Actor`s that belong to the `Instance`."""
+ totalCount: Int!
+}
+
+type InstanceActorsConnectionEdge {
+ """The date/time when the `Actor` was added to the `Instance`."""
+ created: DateTime!
+ cursor: String!
+ node: Actor!
+
+ """
+ The type of the `Actor`: `Application` | `Group` | `Organization` | `Person` | `Service`
+ """
+ type: ActorType!
+
+ """The username of the `Actor`."""
+ username: String!
+}
+
+type InstanceMembersConnection {
+ edges: [InstanceMembersConnectionEdge!]!
+ pageInfo: PageInfo!
+
+ """
+ The total number of `Account`s that belong to the `Instance`. Note that pending members are not counted.
+ """
+ totalCount: Int!
+}
+
+type InstanceMembersConnectionEdge {
+ """
+ The date/time when the `Account` accepted membership in the `Instance`.
+ """
+ accepted: DateTime
+
+ """Whether the `Account` has administrator privileges in the `Instance`."""
+ admin: Boolean!
+
+ """The date/time when the `Account` was added to the `Instance`."""
+ created: DateTime!
+ cursor: String!
+ node: Account!
+}
+
+"""
+The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
+"""
+scalar JSON
+
+type Key implements Node {
+ created: DateTime!
+ id: ID!
+ iri: URL!
+ uuid: UUID!
+ versions: [KeyVersion!]!
+}
+
+type KeyVersion implements Node {
+ fingerprint: String!
+
+ """
+ DrFed observation time, not the remote key rotation time; does not imply continuous use between observations.
+ """
+ firstSeen: DateTime!
+ id: ID!
+ key: Key!
+
+ """
+ DrFed observation time, not the remote key rotation time; does not imply continuous use between observations.
+ """
+ lastSeen: DateTime!
+ publicKey: JSON!
+ uuid: UUID!
+}
+
+"""Represents the local details of an `Actor`."""
+type LocalActor implements Node {
+ """The profile image of the actor."""
+ avatar: String
+
+ """The profile banner image of the actor."""
+ header: String
+
+ """The unique identifier of the local actor details."""
+ id: ID!
+
+ """The UUID of the local actor details."""
+ uuid: UUID!
+}
+
+"""
+Represents a `LocalInstance`, i.e., an `Instance` hosted by this DrFed deployment. Only accepted members of the `Instance` it backs, and site administrators, can read it, because it carries operational details such as the expiry date and the actor quota.
+"""
+type LocalInstance implements Node {
+ """The expire date of the instance."""
+ expires: DateTime!
+
+ """The unique identifier of the `LocalInstance`."""
+ id: ID!
+
+ """
+ The `Instance` this `LocalInstance` backs, which carries the federation-facing data such as the host name.
+ """
+ instance: Instance
+ maxActors: Int!
+ slug: String!
+
+ """The UUID of the `LocalInstance`."""
+ uuid: UUID!
+}
+
+"""An email login challenge."""
+type LoginChallenge {
+ """The public identifier of the login challenge."""
+ challengeId: UUID!
+}
+
+"""The session revoked."""
+type LogoutSuccess {
+ """Revoking status."""
+ revoke: Boolean!
+}
+
+type Mutation {
+ """Complete login challenge."""
+ completeLoginChallenge(challengeId: UUID!, code: String!): Session
+
+ """Create an instance."""
+ createInstance(
+ """
+ A unique instance slug, which becomes the leftmost label of the instance's domain name, e.g. `slug` in `slug.example.com`.
+ """
+ slug: String!
+ ): CreateInstanceResult!
+
+ """
+ Create a local ActivityPub object without delivering it to remote servers.
+ """
+ createObject(
+ """
+ The local author actor ID. The viewer must be an accepted instance member.
+ """
+ actor: ID!
+
+ """
+ Explicit addressing preserved in order, including duplicates. Empty lists are allowed.
+ """
+ addressing: AddressingInput!
+
+ """
+ Non-empty HTML stored verbatim; browser clients must sanitize it before rendering.
+ """
+ contentHtml: String!
+
+ """A BCP 47 language tag, canonicalized and limited to 35 characters."""
+ language: String
+
+ """Optional title. Empty or whitespace-only values are stored as null."""
+ name: String
+
+ """Whether to mark the content as sensitive."""
+ sensitive: Boolean! = false
+
+ """
+ Optional summary or content warning. Empty or whitespace-only values are stored as null.
+ """
+ summary: String
+
+ """The ActivityStreams object type to create."""
+ type: ObjectType! = Note
+ ): CreateObjectResult!
+
+ """Create actors."""
+ generateActors(
+ """The ID of the target instance"""
+ instance: ID!
+
+ """How many actors to generate"""
+ size: Int!
+ ): CreateActorsResult!
+
+ """Send a magic link without revealing whether the account exists."""
+ loginByEmail(
+ """The email address of the `Account`."""
+ email: Email!
+
+ """
+ Use {challengeId} and {code} variables. The URL's origin must be in the server's login allowlist.
+ """
+ verifyUrl: URITemplate!
+ ): LoginChallenge!
+
+ """Revokes a session. Return always `revoke: true`."""
+ revokeSession: LogoutSuccess!
+}
+
+interface Node {
+ id: ID!
+}
+
+"""Represents an ActivityPub object authored by an `Actor`."""
+type Object implements Node {
+ """
+ Activities referencing this object, optionally filtered by type. Excludes deleted authors; ordered by published ASC, id ASC.
+ """
+ activities(after: String, before: String, first: Int, last: Int, type: ActivityType): ObjectActivitiesConnection!
+
+ """The actor that authored the object."""
+ actor: Actor!
+
+ """Stored audience occurrences, in original order including duplicates."""
+ audience: [Resource!]!
+
+ """Stored bcc occurrences, in original order including duplicates."""
+ bcc: [Resource!]!
+
+ """Stored bto occurrences, in original order including duplicates."""
+ bto: [Resource!]!
+
+ """Stored cc occurrences, in original order including duplicates."""
+ cc: [Resource!]!
+
+ """
+ HTML preserved verbatim. Clients must sanitize it before browser rendering.
+ """
+ contentHtml: String!
+
+ """
+ The time the object was stored in DrFed, distinct from its publication time.
+ """
+ created: DateTime!
+ document: JSON
+
+ """The Relay global ID of the object."""
+ id: ID!
+ iri: URL!
+
+ """The canonical BCP 47 tag used for contentMap, if specified."""
+ language: String
+
+ """The optional title of the object."""
+ name: String
+
+ """The ActivityStreams publication time."""
+ published: DateTime!
+ resource: Resource!
+
+ """Whether the content is marked sensitive."""
+ sensitive: Boolean!
+
+ """The optional summary or content warning."""
+ summary: String
+
+ """Stored to occurrences, in original order including duplicates."""
+ to: [Resource!]!
+
+ """The ActivityStreams vocabulary type: Note or Article."""
+ type: ObjectType!
+
+ """The time the stored object was last updated."""
+ updated: DateTime!
+
+ """The human-readable page URL, if available."""
+ url: URL
+
+ """The UUID of the ActivityPub Object."""
+ uuid: UUID!
+}
+
+type ObjectActivitiesConnection {
+ edges: [ObjectActivitiesConnectionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ObjectActivitiesConnectionEdge {
+ cursor: String!
+ node: Activity!
+}
+
+type ObjectConnection {
+ edges: [ObjectEdge!]!
+ pageInfo: PageInfo!
+
+ """
+ The number of non-deleted objects authored by this actor, regardless of addressing.
+ """
+ totalCount: Int!
+}
+
+type ObjectEdge {
+ cursor: String!
+ node: Object!
+}
+
+enum ObjectType {
+ Article
+ Note
+}
+
+type PageInfo {
+ endCursor: String
+ hasNextPage: Boolean!
+ hasPreviousPage: Boolean!
+ startCursor: String
+}
+
+type Query {
+ """Get an `Account` by its UUID."""
+ accountByUuid(
+ """The UUID of the `Account` to retrieve."""
+ uuid: UUID!
+ ): Account
+
+ """
+ Get a `LocalInstance` by its slug. Returns `null` if no `LocalInstance` has the given slug.
+ """
+ localInstanceBySlug(
+ """
+ The slug of the `LocalInstance` to retrieve, i.e., the label that forms the first part of its host name.
+ """
+ slug: String!
+ ): LocalInstance
+ node(id: ID!): Node
+ nodes(ids: [ID!]!): [Node]!
+
+ """`Account` if authorized, else `null`"""
+ viewer: Account
+}
+
+type Resource implements Node {
+ """
+ Typed details, or null while unknown or when the typed row or its author/owner is deleted.
+ """
+ detail: ResourceDetail
+ id: ID!
+ iri: URL!
+ kind: ResourceKind!
+}
+
+union ResourceDetail = Activity | Actor | Collection | Object
+
+enum ResourceKind {
+ activity
+ actor
+ collection
+ object
+ unknown
+}
+
+type Session {
+ accessToken: String
+ account: Account!
+ created: DateTime!
+ expires: DateTime!
+ id: UUID!
+}
+
+scalar URITemplate
+
+"""
+A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
+"""
+scalar URL
+
+"""
+A field whose value is a generic Universally Unique Identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier.
+"""
+scalar UUID
\ No newline at end of file
From 73256e8b40ffa0ab75ca819ee1ed393b612689cd Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Wed, 30 Sep 2026 05:03:24 +0900
Subject: [PATCH 02/13] Cleanup AI genned
---
.gitignore | 1 -
.oxfmtrc.json | 3 +-
packages/drfed/src/serving.ts | 4 +-
packages/graphql/package.json | 6 +-
packages/graphql/src/activity-log.test.ts | 3 +-
.../entry.ts} | 64 +-
packages/graphql/src/activity-log/inbound.ts | 76 +-
packages/graphql/src/federation.ts | 12 +-
packages/graphql/src/key.ts | 57 ++
packages/graphql/src/schema.ts | 8 +-
packages/models/src/activity-log.test.ts | 2 +
packages/models/src/key.test.ts | 2 +
packages/models/src/key.ts | 20 +-
packages/models/src/schema.ts | 5 +-
packages/web/schema.graphql | 764 ------------------
15 files changed, 153 insertions(+), 874 deletions(-)
rename packages/graphql/src/{activity-log.ts => activity-log/entry.ts} (70%)
create mode 100644 packages/graphql/src/key.ts
delete mode 100644 packages/web/schema.graphql
diff --git a/.gitignore b/.gitignore
index bc1d46f..3cdfb96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
.DS_Store
.env
*.graphql
-!packages/web/schema.graphql
*.graphql.ts
__generated__/
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index f4883f5..0c1ebc8 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -9,7 +9,6 @@
".agents/skills/",
".claude/skills/",
"plans/",
- "**/__generated__/**",
- "packages/web/schema.graphql"
+ "**/__generated__/**"
]
}
diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts
index af466dc..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 federation surface (including the inbound recorder) 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(
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 6b7c47d..e4d33d1 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -83,8 +83,8 @@
"default": "./dist/origin.mjs"
},
"./activity-log": {
- "types": "./dist/activity-log.d.mts",
- "default": "./dist/activity-log.mjs"
+ "types": "./dist/activity-log/entry.d.mts",
+ "default": "./dist/activity-log/entry.mjs"
}
},
"files": [
@@ -103,7 +103,7 @@
"src/schema.ts",
"src/object.ts",
"src/origin.ts",
- "src/activity-log.ts"
+ "src/activity-log/entry.ts"
],
"dts": {
"sourcemap": true,
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
index 082875a..929389b 100644
--- a/packages/graphql/src/activity-log.test.ts
+++ b/packages/graphql/src/activity-log.test.ts
@@ -15,7 +15,8 @@
// 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
+
+// oxlint-disable no-await-in-loop max-statements id-length
import assert from "node:assert/strict";
import { it } from "node:test";
diff --git a/packages/graphql/src/activity-log.ts b/packages/graphql/src/activity-log/entry.ts
similarity index 70%
rename from packages/graphql/src/activity-log.ts
rename to packages/graphql/src/activity-log/entry.ts
index 51ad03c..ab7116b 100644
--- a/packages/graphql/src/activity-log.ts
+++ b/packages/graphql/src/activity-log/entry.ts
@@ -21,19 +21,16 @@ import {
import type { Uuid } from "@drfed/models/uuid";
import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle";
-import builder, { type DrFedObjectRef } from "./builder.ts";
+import builder, { type DrFedObjectRef } from "../builder.ts";
-export { createKeyCache } from "./activity-log/keycache.ts";
-export {
- classifyInbound,
- createInboundRecorder,
-} from "./activity-log/inbound.ts";
-export { describeActivity } from "./activity-log/describe.ts";
+export { createKeyCache } from "./keycache.ts";
+export { classifyInbound, createInboundRecorder } from "./inbound.ts";
+export { describeActivity } from "./describe.ts";
export {
deliverActivity,
createOutboxErrorHandler,
createPermanentFailureHandler,
-} from "./activity-log/outbound.ts";
+} from "./outbound.ts";
const ActivityLogDirection = builder.enumType("ActivityLogDirection", {
values: activityLogDirectionEnum.enumValues,
@@ -53,44 +50,6 @@ const access = (localId: Uuid | null) =>
? false
: { $any: { admin: true as const, localInstanceMember: localId } };
-const KeyRef = builder.drizzleNode("keys", {
- name: "Key",
- 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.relation("versions", {
- 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",
- 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: observationDescription,
- }),
- lastSeen: t.expose("lastSeen", {
- type: "DateTime",
- description: observationDescription,
- }),
- }),
-});
-export const KeyVersion: DrFedObjectRef = KeyVersionRef;
const ActivityLogRef = builder.drizzleNode("activityLogs", {
name: "ActivityLog",
select: { with: { instance: { columns: { localId: true } } } },
@@ -111,7 +70,8 @@ const ActivityLogRef = builder.drizzleNode("activityLogs", {
verificationKey: t.relation("verificationKey", {
nullable: true,
description:
- "The public key version used in verification, even when it failed. Its presence does not imply success; consult status.",
+ "The public key version used in verification, even when it failed. " +
+ "Its presence does not imply success; consult status.",
}),
remoteActorIri: t.expose("remoteActorIri", { type: "URL", nullable: true }),
remoteHost: t.exposeString("remoteHost", { nullable: true }),
@@ -122,7 +82,9 @@ const ActivityLogRef = builder.drizzleNode("activityLogs", {
type: "JSON",
nullable: true,
description:
- "Original inbound JSON or compact outbound JSON-LD. May contain unverified remote input and private recipients. Null represents a literal JSON null body.",
+ "Original inbound JSON or compact outbound JSON-LD. " +
+ "May contain unverified remote input and private recipients. " +
+ "Null represents a literal JSON null body.",
}),
created: t.expose("created", { type: "DateTime" }),
}),
@@ -148,7 +110,8 @@ builder.drizzleObjectField("instances", "activityLogs", (t) =>
type: ActivityLog,
args: { filter: t.arg({ type: ActivityLogFilter }) },
description:
- "Delivery observations, newest first. Restricted to local instance members and administrators.",
+ "Delivery observations, newest first. " +
+ "Restricted to local instance members and administrators.",
select: (args, ctx, nestedSelection) =>
({
columns: { localId: true },
@@ -166,7 +129,8 @@ builder.drizzleObjectField("actors", "activityLogs", (t) =>
type: ActivityLog,
args: { filter: t.arg({ type: ActivityLogFilter }) },
description:
- "This local actor's delivery observations, newest first. Restricted to instance members and administrators.",
+ "This local actor's delivery observations, newest first. " +
+ "Restricted to instance members and administrators.",
select: (args, ctx, nestedSelection) =>
({
columns: { localId: true },
diff --git a/packages/graphql/src/activity-log/inbound.ts b/packages/graphql/src/activity-log/inbound.ts
index ad9cba5..a82db72 100644
--- a/packages/graphql/src/activity-log/inbound.ts
+++ b/packages/graphql/src/activity-log/inbound.ts
@@ -63,7 +63,9 @@ function verificationError(result: VerifyRequestDetailedResult): string | null {
if (result.verified) return null;
const { reason } = result;
if (reason.type !== "keyFetchError") return reason.type;
- return `keyFetchError: ${"status" in reason.result ? reason.result.status : reason.result.error.name}`;
+ return `keyFetchError: ${
+ "status" in reason.result ? reason.result.status : reason.result.error.name
+ }`;
}
type Loaders = Pick<
@@ -90,29 +92,29 @@ async function observeVerification(
await keyCache.set(id, key);
},
};
- let verification: VerifyRequestDetailedResult | undefined;
- let verificationKeyId: Uuid | null = null;
- // Capture key material before Fedify or another request can refresh the cache.
+ let result: VerifyRequestDetailedResult | undefined;
+ let keyId: Uuid | null = null;
+ // Capture key material before Fedify or
+ // another request can refresh the cache.
try {
- verification = await verifyRequestDetailed(request, {
+ result = await verifyRequestDetailed(request, {
keyCache: observedCache,
...loaders,
});
- const key = verification.verified
- ? verification.key
- : verification.reason.type === "invalidSignature" &&
- verification.reason.keyId != null
- ? observedKeys.get(verification.reason.keyId.href)
+ const key = result.verified
+ ? result.key
+ : result.reason.type === "invalidSignature" && result.reason.keyId != null
+ ? observedKeys.get(result.reason.keyId.href)
: null;
- const keyId = verification.verified
- ? verification.key.id
- : verification.reason.type === "noSignature"
+ const verifiedKeyId = result.verified
+ ? result.key.id
+ : result.reason.type === "noSignature"
? null
- : verification.reason.keyId;
- if (key?.publicKey != null && keyId != null) {
- verificationKeyId = (
+ : result.reason.keyId;
+ if (key?.publicKey != null && verifiedKeyId != null) {
+ keyId = (
await observeKeyVersion(db, {
- iri: keyId.href,
+ iri: verifiedKeyId.href,
publicKey: await exportJwk(key.publicKey),
})
).id;
@@ -122,7 +124,7 @@ async function observeVerification(
error,
});
}
- return { verification, verificationKeyId };
+ return { result, keyId };
}
async function findRecordingInstance(db: Database, host: string) {
@@ -165,30 +167,32 @@ export function createInboundRecorder({
async fetch(request, options) {
const ctx = federation.createContext(request, options.contextData);
const route = ctx.parseUri(new URL(request.url));
- if (request.method !== "POST" || 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);
- if (instance == null) return await federation.fetch(request, options);
- let payload: unknown;
- try {
- payload = await request.clone().json();
- } catch {
- return await federation.fetch(request, options);
+ const payload: unknown = await request
+ .clone()
+ .json()
+ .catch((e) => e);
+ const response = await federation.fetch(request, options);
+ if (
+ request.method !== "POST" ||
+ route?.type !== "inbox" ||
+ // Unclaimed subdomains must not create orphaned public-key history.
+ instance == null ||
+ payload instanceof Error
+ ) {
+ return response;
}
const loaders = {
documentLoader: ctx.documentLoader,
contextLoader: ctx.contextLoader,
};
const keyCache = createKeyCache(kv, publicKeyPrefix, loaders);
- const { verification, verificationKeyId } = await observeVerification(
+ const verification = await observeVerification(
db,
request,
keyCache,
loaders,
);
- const response = await federation.fetch(request, options);
try {
const actor =
route.identifier != null && validateUuid(route.identifier)
@@ -200,27 +204,27 @@ export function createInboundRecorder({
},
})
: null;
- const signedKeyIri = signedKeyId(verification)?.href ?? null;
+ const signedKeyIri = signedKeyId(verification.result)?.href ?? null;
const description = await describeActivity(payload, loaders);
await recordInbound(db, {
...description,
instanceId: instance.id,
actorId: actor?.id ?? null,
status:
- verification == null
+ verification.result == null
? response.ok
? "received"
: "unverified"
- : classifyInbound(verification, response.status),
+ : classifyInbound(verification.result, response.status),
signedKeyIri,
- verificationKeyId,
+ verificationKeyId: verification.keyId,
remoteHost: remoteHost(description.remoteActorIri, signedKeyIri),
inboxUrl: request.url,
statusCode: response.status,
error:
- verification == null
+ verification.result == null
? "Verification observation failed"
- : verificationError(verification),
+ : verificationError(verification.result),
payload,
});
} catch (error) {
diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts
index d45087e..6b042af 100644
--- a/packages/graphql/src/federation.ts
+++ b/packages/graphql/src/federation.ts
@@ -486,14 +486,22 @@ function recipients(rows: readonly StoredAddressing[]): {
* @returns An EXISTS predicate matching explicit Public addressing.
*/
function publicAddressing(sourceId: SQLWrapper): SQL {
- return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
+ return sql`exists (select 1 from ${schema.addressing} where ${
+ schema.addressing.sourceId
+ } = ${sourceId} and ${
+ schema.addressing.targetId
+ } = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
}
function servedActivity(table: {
id: SQLWrapper;
objectId: SQLWrapper;
type: SQLWrapper;
}): SQL {
- return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`;
+ return sql`${table.type} = 'Create' and ${publicAddressing(
+ table.id,
+ )} and exists (select 1 from ${schema.objects} where ${
+ schema.objects.id
+ } = ${table.objectId} and ${schema.objects.deleted} is null)`;
}
function collectionIri(actor: StoredActor, role: string): URL | null {
const reference = actor.collectionReferences.find(
diff --git a/packages/graphql/src/key.ts b/packages/graphql/src/key.ts
new file mode 100644
index 0000000..9c5ea1c
--- /dev/null
+++ b/packages/graphql/src/key.ts
@@ -0,0 +1,57 @@
+// 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",
+ 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.relation("versions", {
+ 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",
+ 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/schema.ts b/packages/graphql/src/schema.ts
index 3912397..04cb2c4 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -14,12 +14,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
// oxlint-disable import/no-unassigned-import
+
import "./account.ts";
-import "./instance.ts";
-import "./auth/entry.ts";
+import "./activity-log/entry.ts";
import "./actor.ts";
+import "./auth/entry.ts";
+import "./instance.ts";
+import "./key.ts";
import "./object.ts";
-import "./activity-log.ts";
import builder from "./builder.ts";
builder.queryType({});
diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts
index ccbef35..34500d8 100644
--- a/packages/models/src/activity-log.test.ts
+++ b/packages/models/src/activity-log.test.ts
@@ -14,6 +14,8 @@
// 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";
diff --git a/packages/models/src/key.test.ts b/packages/models/src/key.test.ts
index 23e4225..c4a3743 100644
--- a/packages/models/src/key.test.ts
+++ b/packages/models/src/key.test.ts
@@ -14,6 +14,8 @@
// 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";
diff --git a/packages/models/src/key.ts b/packages/models/src/key.ts
index 6454aaf..b763891 100644
--- a/packages/models/src/key.ts
+++ b/packages/models/src/key.ts
@@ -14,16 +14,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import type { webcrypto } from "node:crypto";
-
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";
-/** Public JWK fields including optional JOSE metadata. */
-export type PublicJwk = webcrypto.JsonWebKey & { kid?: string; use?: string };
+/** Web Crypto's JWK export type, including optional JOSE metadata. */
+export type PublicJwk = Exclude<
+ Awaited>,
+ ArrayBuffer
+> & { kid?: string; use?: string };
const privateParameters = [
"d",
@@ -40,7 +41,7 @@ const privateParameters = [
* 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): webcrypto.JsonWebKey {
+export function toPublicJwk(jwk: PublicJwk): PublicJwk {
if (privateParameters.some((name) => name in jwk)) {
throw new TypeError("Expected an asymmetric public JWK.");
}
@@ -56,10 +57,12 @@ export function thumbprintInput(jwk: PublicJwk): string {
const key = toPublicJwk(jwk);
const required =
key.kty === "RSA"
- ? { e: key.e, kty: key.kty, n: key.n }
+ ? // 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-able id-length
if (
required == null ||
Object.values(required).some(
@@ -80,7 +83,10 @@ export async function jwkThumbprint(jwk: PublicJwk): Promise {
"SHA-256",
new TextEncoder().encode(thumbprintInput(jwk)),
);
- return Buffer.from(digest).toString("base64url");
+ return new Uint8Array(digest).toBase64({
+ alphabet: "base64url",
+ omitPadding: true,
+ });
}
/**
diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts
index 73746a6..d721d4d 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -14,8 +14,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import type { webcrypto } from "node:crypto";
-
import { desc, sql } from "drizzle-orm";
import {
type AnyPgColumn,
@@ -36,6 +34,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. */
@@ -547,7 +546,7 @@ export const keyVersions = pgTable(
.$type()
.notNull()
.references(() => keys.id, { onDelete: "restrict" }),
- publicKey: jsonb("public_key").$type().notNull(),
+ 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(),
diff --git a/packages/web/schema.graphql b/packages/web/schema.graphql
deleted file mode 100644
index acb1e23..0000000
--- a/packages/web/schema.graphql
+++ /dev/null
@@ -1,764 +0,0 @@
-"""
-Represents an `Account` in the DrFed platform. Note that it differs from the ActivityPub `Actor`s that belong to `Instance`s.
-"""
-type Account implements Node {
- """
- Whether the `Account` has administrator privileges. `null` unless the viewer is the `Account` itself or a site administrator.
- """
- admin: Boolean
-
- """The date/time when the `Account` was created."""
- created: DateTime!
-
- """
- The email address of the `Account`. `null` unless the viewer is the `Account` itself or a site administrator.
- """
- email: Email
-
- """The unique identifier of the `Account`."""
- id: ID!
-
- """
- The `Instance`s that the `Account` belongs to. `null` unless the viewer is the `Account` itself or a site administrator; an empty connection means the `Account` belongs to no `Instance`.
- """
- instances(after: String, before: String, first: Int, last: Int): AccountInstancesConnection
-
- """The display name of the `Account`."""
- name: String!
-
- """The UUID of the `Account`."""
- uuid: UUID!
-}
-
-type AccountInstancesConnection {
- edges: [AccountInstancesConnectionEdge!]!
- pageInfo: PageInfo!
-
- """
- The total number of `Instance`s that the `Account` belongs to.Note that pending memberships are not counted.
- """
- totalCount: Int!
-}
-
-type AccountInstancesConnectionEdge {
- """
- The date/time when the `Account` accepted membership in the `Instance`.
- """
- accepted: DateTime
-
- """Whether the `Account` has administrator privileges in the `Instance`."""
- admin: Boolean!
-
- """The date/time when the `Account` was added to the `Instance`."""
- created: DateTime!
- cursor: String!
- node: Instance!
-}
-
-type Activity implements Node {
- actor: Actor!
-
- """Stored audience occurrences, in original order including duplicates."""
- audience: [Resource!]!
-
- """Stored bcc occurrences, in original order including duplicates."""
- bcc: [Resource!]!
-
- """Stored bto occurrences, in original order including duplicates."""
- bto: [Resource!]!
-
- """Stored cc occurrences, in original order including duplicates."""
- cc: [Resource!]!
- document: JSON
-
- """
- Expected classifications for this activity and its Object. Empty if the object is absent, hidden, or not an Object.
- """
- expectedClassifications: [ExpectedClassification!]!
- id: ID!
- iri: URL!
- object: Resource
- published: DateTime!
- resource: Resource!
-
- """Stored to occurrences, in original order including duplicates."""
- to: [Resource!]!
- type: ActivityType!
-}
-
-type ActivityLog implements Node {
- activityIri: URL
- actor: Actor
- created: DateTime!
- direction: ActivityLogDirection!
- error: String
- id: ID!
- inboxUrl: URL!
- instance: Instance!
- objectIri: URL
- objectType: String
-
- """
- Original inbound JSON or compact outbound JSON-LD. May contain unverified remote input and private recipients. Null represents a literal JSON null body.
- """
- payload: JSON
- remoteActorIri: URL
- remoteHost: String
- signedKeyIri: URL
- status: ActivityLogStatus!
- statusCode: Int
- type: String
- uuid: UUID!
-
- """
- The public key version used in verification, even when it failed. Its presence does not imply success; consult status.
- """
- verificationKey: KeyVersion
-}
-
-enum ActivityLogDirection {
- inbound
- outbound
-}
-
-input ActivityLogFilter {
- direction: ActivityLogDirection
- status: ActivityLogStatus
- type: String
-}
-
-enum ActivityLogStatus {
- failed
- permanently_failed
- queued
- received
- rejected
- sent
- unverified
-}
-
-enum ActivityType {
- Create
-}
-
-"""Represents an `Actor` in the DrFed platform."""
-type Actor implements Node {
- """
- This local actor's delivery observations, newest first. Restricted to instance members and administrators.
- """
- activityLogs(after: String, before: String, filter: ActivityLogFilter, first: Int, last: Int): ActorActivityLogsConnection!
-
- """The avatar URL of the `Actor`."""
- avatarUrl: URL
-
- """The creation date/time of the `Actor`."""
- created: DateTime!
- featured: Collection
- followers: Collection
- following: Collection
-
- """The handle of the `Actor`."""
- handle: String!
-
- """The header URL of the `Actor`."""
- headerUrl: URL
-
- """The unique identifier of the `Actor`."""
- id: ID!
-
- """The inbox URL of the `Actor`."""
- inboxUrl: URL!
-
- """The `Instance` that the `Actor` belongs to."""
- instance: Instance!
- iri: URL!
-
- """The local details of the `Actor`, or null if it is remote."""
- local: LocalActor
-
- """
- Non-deleted objects, newest publication first. All addressing is publicly readable through GraphQL.
- """
- objects(after: String, before: String, first: Int, last: Int): ObjectConnection!
-
- """
- The stored outbox collection. For local actors, it contains every stored `Create` activity regardless of addressing and retains activities whose objects are deleted so their recorded history remains inspectable. The ActivityPub outbox serves only activities with Public addressing whose objects are not deleted.
- """
- outbox: Collection
-
- """The profile URL of the `Actor`."""
- profileUrl: URL
- resource: Resource!
-
- """
- The type of the `Actor`: `Application` | `Group` | `Organization` | `Person` | `Service`
- """
- type: ActorType!
-
- """The username of the `Actor`."""
- username: String!
-
- """The UUID of the `Actor`."""
- uuid: UUID!
-}
-
-type ActorActivityLogsConnection {
- edges: [ActorActivityLogsConnectionEdge!]!
- pageInfo: PageInfo!
-}
-
-type ActorActivityLogsConnectionEdge {
- cursor: String!
- node: ActivityLog!
-}
-
-enum ActorType {
- Application
- Group
- Organization
- Person
- Service
-}
-
-input AddressingInput {
- audience: [URL!]! = []
- bcc: [URL!]! = []
- bto: [URL!]! = []
- cc: [URL!]! = []
- to: [URL!]! = []
-}
-
-type Collection implements Node {
- """
- The `totalItems` reported by the collection document. It can differ from the locally observed count and is null for local collections. Unlike `totalCount` and `items`, this value is not recalculated when deleted actors, deleted objects, or resources authored by deleted actors are excluded.
- """
- declaredTotalItems: Int
- id: ID!
- iri: URL!
-
- """
- The locally stored, visible members. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this connection and `totalCount`.
- """
- items(after: String, before: String, first: Int, last: Int): CollectionItemsConnection!
- owner: Actor
- resource: Resource!
-
- """
- The number of locally stored, visible members. It can differ from `declaredTotalItems`. Deleted actors, deleted objects, and resources authored by deleted actors are excluded from this count and `items`.
- """
- totalCount: Int!
- type: CollectionType!
-}
-
-type CollectionItemsConnection {
- edges: [CollectionItemsConnectionEdge!]!
- pageInfo: PageInfo!
-}
-
-type CollectionItemsConnectionEdge {
- cursor: String!
- node: Resource!
-}
-
-enum CollectionType {
- Collection
- OrderedCollection
-}
-
-"""Represents an error that occurred while creating an `Actor`."""
-type CreateActorsError {
- """
- A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
- """
- message: String!
-
- """The type of the error. Use this for programmatic error handling."""
- type: CreateActorsErrorType!
-}
-
-enum CreateActorsErrorType {
- InstanceNotFound
- InvalidSize
- TooManyActors
-}
-
-union CreateActorsResult = CreateActorsError | CreateActorsSuccess
-
-type CreateActorsSuccess {
- actors: [Actor!]!
-}
-
-"""Represents an error that occurred while creating an `Instance`."""
-type CreateInstanceError {
- """
- A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
- """
- message: String!
-
- """The type of the error. Use this for programmatic error handling."""
- type: CreateInstanceErrorType!
-}
-
-enum CreateInstanceErrorType {
- InvalidSlug
- SlugAlreadyTaken
- TooManyInstances
-}
-
-union CreateInstanceResult = CreateInstanceError | Instance
-
-type CreateObjectError {
- """
- A human-readable message describing the error. Don't use this for programmatic error handling, use the `type` field instead.
- """
- message: String!
-
- """The type of the error. Use this for programmatic error handling."""
- type: CreateObjectErrorType!
-}
-
-enum CreateObjectErrorType {
- ActorNotFound
- InvalidContent
- InvalidLanguage
-}
-
-union CreateObjectResult = CreateObjectError | Object
-
-"""An ISO 8601 instant preserving sub-millisecond precision."""
-scalar DateTime
-
-scalar Email
-
-"""
-Expected classification; actual access depends on receiver state and policy.
-"""
-type ExpectedClassification {
- classification: String!
- implementation: Implementation!
- reason: String!
- version: String!
-}
-
-enum Implementation {
- MASTODON
- MISSKEY
-}
-
-"""Represents an `Instance` in the DrFed platform."""
-type Instance implements Node {
- """
- Delivery observations, newest first. Restricted to local instance members and administrators.
- """
- activityLogs(after: String, before: String, filter: ActivityLogFilter, first: Int, last: Int): InstanceActivityLogsConnection!
-
- """The `Actor`s that belong to the `Instance`."""
- actors(after: String, before: String, first: Int, last: Int): InstanceActorsConnection!
-
- """The creation date/time of the `Instance`."""
- created: DateTime!
- host: String!
-
- """The unique identifier of the `Instance`."""
- id: ID!
-
- """
- The `LocalInstance` backing the `Instance` when it is hosted by this DrFed deployment. `null` if the `Instance` is remote, i.e., hosted by another server on the fediverse.
- """
- localInstance: LocalInstance
-
- """The `Account`s that belong to the `Instance`."""
- members(after: String, before: String, first: Int, last: Int): InstanceMembersConnection!
- nodeInfoUrl: String
- software: String
- softwareVersion: String
-
- """
- The absolute origin the `Instance` is served at, e.g. `https://foo-bar.drfed.net`. Local instances follow this deployment's root origin, so a development deployment yields an `http:` URL carrying its port; remote instances are always `https:`.
- """
- url: String!
- uuid: UUID!
-}
-
-type InstanceActivityLogsConnection {
- edges: [InstanceActivityLogsConnectionEdge!]!
- pageInfo: PageInfo!
-}
-
-type InstanceActivityLogsConnectionEdge {
- cursor: String!
- node: ActivityLog!
-}
-
-type InstanceActorsConnection {
- edges: [InstanceActorsConnectionEdge!]!
- pageInfo: PageInfo!
-
- """The total number of `Actor`s that belong to the `Instance`."""
- totalCount: Int!
-}
-
-type InstanceActorsConnectionEdge {
- """The date/time when the `Actor` was added to the `Instance`."""
- created: DateTime!
- cursor: String!
- node: Actor!
-
- """
- The type of the `Actor`: `Application` | `Group` | `Organization` | `Person` | `Service`
- """
- type: ActorType!
-
- """The username of the `Actor`."""
- username: String!
-}
-
-type InstanceMembersConnection {
- edges: [InstanceMembersConnectionEdge!]!
- pageInfo: PageInfo!
-
- """
- The total number of `Account`s that belong to the `Instance`. Note that pending members are not counted.
- """
- totalCount: Int!
-}
-
-type InstanceMembersConnectionEdge {
- """
- The date/time when the `Account` accepted membership in the `Instance`.
- """
- accepted: DateTime
-
- """Whether the `Account` has administrator privileges in the `Instance`."""
- admin: Boolean!
-
- """The date/time when the `Account` was added to the `Instance`."""
- created: DateTime!
- cursor: String!
- node: Account!
-}
-
-"""
-The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
-"""
-scalar JSON
-
-type Key implements Node {
- created: DateTime!
- id: ID!
- iri: URL!
- uuid: UUID!
- versions: [KeyVersion!]!
-}
-
-type KeyVersion implements Node {
- fingerprint: String!
-
- """
- DrFed observation time, not the remote key rotation time; does not imply continuous use between observations.
- """
- firstSeen: DateTime!
- id: ID!
- key: Key!
-
- """
- DrFed observation time, not the remote key rotation time; does not imply continuous use between observations.
- """
- lastSeen: DateTime!
- publicKey: JSON!
- uuid: UUID!
-}
-
-"""Represents the local details of an `Actor`."""
-type LocalActor implements Node {
- """The profile image of the actor."""
- avatar: String
-
- """The profile banner image of the actor."""
- header: String
-
- """The unique identifier of the local actor details."""
- id: ID!
-
- """The UUID of the local actor details."""
- uuid: UUID!
-}
-
-"""
-Represents a `LocalInstance`, i.e., an `Instance` hosted by this DrFed deployment. Only accepted members of the `Instance` it backs, and site administrators, can read it, because it carries operational details such as the expiry date and the actor quota.
-"""
-type LocalInstance implements Node {
- """The expire date of the instance."""
- expires: DateTime!
-
- """The unique identifier of the `LocalInstance`."""
- id: ID!
-
- """
- The `Instance` this `LocalInstance` backs, which carries the federation-facing data such as the host name.
- """
- instance: Instance
- maxActors: Int!
- slug: String!
-
- """The UUID of the `LocalInstance`."""
- uuid: UUID!
-}
-
-"""An email login challenge."""
-type LoginChallenge {
- """The public identifier of the login challenge."""
- challengeId: UUID!
-}
-
-"""The session revoked."""
-type LogoutSuccess {
- """Revoking status."""
- revoke: Boolean!
-}
-
-type Mutation {
- """Complete login challenge."""
- completeLoginChallenge(challengeId: UUID!, code: String!): Session
-
- """Create an instance."""
- createInstance(
- """
- A unique instance slug, which becomes the leftmost label of the instance's domain name, e.g. `slug` in `slug.example.com`.
- """
- slug: String!
- ): CreateInstanceResult!
-
- """
- Create a local ActivityPub object without delivering it to remote servers.
- """
- createObject(
- """
- The local author actor ID. The viewer must be an accepted instance member.
- """
- actor: ID!
-
- """
- Explicit addressing preserved in order, including duplicates. Empty lists are allowed.
- """
- addressing: AddressingInput!
-
- """
- Non-empty HTML stored verbatim; browser clients must sanitize it before rendering.
- """
- contentHtml: String!
-
- """A BCP 47 language tag, canonicalized and limited to 35 characters."""
- language: String
-
- """Optional title. Empty or whitespace-only values are stored as null."""
- name: String
-
- """Whether to mark the content as sensitive."""
- sensitive: Boolean! = false
-
- """
- Optional summary or content warning. Empty or whitespace-only values are stored as null.
- """
- summary: String
-
- """The ActivityStreams object type to create."""
- type: ObjectType! = Note
- ): CreateObjectResult!
-
- """Create actors."""
- generateActors(
- """The ID of the target instance"""
- instance: ID!
-
- """How many actors to generate"""
- size: Int!
- ): CreateActorsResult!
-
- """Send a magic link without revealing whether the account exists."""
- loginByEmail(
- """The email address of the `Account`."""
- email: Email!
-
- """
- Use {challengeId} and {code} variables. The URL's origin must be in the server's login allowlist.
- """
- verifyUrl: URITemplate!
- ): LoginChallenge!
-
- """Revokes a session. Return always `revoke: true`."""
- revokeSession: LogoutSuccess!
-}
-
-interface Node {
- id: ID!
-}
-
-"""Represents an ActivityPub object authored by an `Actor`."""
-type Object implements Node {
- """
- Activities referencing this object, optionally filtered by type. Excludes deleted authors; ordered by published ASC, id ASC.
- """
- activities(after: String, before: String, first: Int, last: Int, type: ActivityType): ObjectActivitiesConnection!
-
- """The actor that authored the object."""
- actor: Actor!
-
- """Stored audience occurrences, in original order including duplicates."""
- audience: [Resource!]!
-
- """Stored bcc occurrences, in original order including duplicates."""
- bcc: [Resource!]!
-
- """Stored bto occurrences, in original order including duplicates."""
- bto: [Resource!]!
-
- """Stored cc occurrences, in original order including duplicates."""
- cc: [Resource!]!
-
- """
- HTML preserved verbatim. Clients must sanitize it before browser rendering.
- """
- contentHtml: String!
-
- """
- The time the object was stored in DrFed, distinct from its publication time.
- """
- created: DateTime!
- document: JSON
-
- """The Relay global ID of the object."""
- id: ID!
- iri: URL!
-
- """The canonical BCP 47 tag used for contentMap, if specified."""
- language: String
-
- """The optional title of the object."""
- name: String
-
- """The ActivityStreams publication time."""
- published: DateTime!
- resource: Resource!
-
- """Whether the content is marked sensitive."""
- sensitive: Boolean!
-
- """The optional summary or content warning."""
- summary: String
-
- """Stored to occurrences, in original order including duplicates."""
- to: [Resource!]!
-
- """The ActivityStreams vocabulary type: Note or Article."""
- type: ObjectType!
-
- """The time the stored object was last updated."""
- updated: DateTime!
-
- """The human-readable page URL, if available."""
- url: URL
-
- """The UUID of the ActivityPub Object."""
- uuid: UUID!
-}
-
-type ObjectActivitiesConnection {
- edges: [ObjectActivitiesConnectionEdge!]!
- pageInfo: PageInfo!
-}
-
-type ObjectActivitiesConnectionEdge {
- cursor: String!
- node: Activity!
-}
-
-type ObjectConnection {
- edges: [ObjectEdge!]!
- pageInfo: PageInfo!
-
- """
- The number of non-deleted objects authored by this actor, regardless of addressing.
- """
- totalCount: Int!
-}
-
-type ObjectEdge {
- cursor: String!
- node: Object!
-}
-
-enum ObjectType {
- Article
- Note
-}
-
-type PageInfo {
- endCursor: String
- hasNextPage: Boolean!
- hasPreviousPage: Boolean!
- startCursor: String
-}
-
-type Query {
- """Get an `Account` by its UUID."""
- accountByUuid(
- """The UUID of the `Account` to retrieve."""
- uuid: UUID!
- ): Account
-
- """
- Get a `LocalInstance` by its slug. Returns `null` if no `LocalInstance` has the given slug.
- """
- localInstanceBySlug(
- """
- The slug of the `LocalInstance` to retrieve, i.e., the label that forms the first part of its host name.
- """
- slug: String!
- ): LocalInstance
- node(id: ID!): Node
- nodes(ids: [ID!]!): [Node]!
-
- """`Account` if authorized, else `null`"""
- viewer: Account
-}
-
-type Resource implements Node {
- """
- Typed details, or null while unknown or when the typed row or its author/owner is deleted.
- """
- detail: ResourceDetail
- id: ID!
- iri: URL!
- kind: ResourceKind!
-}
-
-union ResourceDetail = Activity | Actor | Collection | Object
-
-enum ResourceKind {
- activity
- actor
- collection
- object
- unknown
-}
-
-type Session {
- accessToken: String
- account: Account!
- created: DateTime!
- expires: DateTime!
- id: UUID!
-}
-
-scalar URITemplate
-
-"""
-A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
-"""
-scalar URL
-
-"""
-A field whose value is a generic Universally Unique Identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier.
-"""
-scalar UUID
\ No newline at end of file
From 983601f6bcde81ca1f5ed0923f91d20a51b1a1ab Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:22 +0900
Subject: [PATCH 03/13] Store activity log attempts and actor links
Extend activity_logs with the verification mechanism and result, the
raw request body, headers, and URL, the response body, every type, the
recipient IRIs, and the completion time. Add activity_log_actors,
which links an inbound log to the actors it addresses, and
activity_log_attempts, which keeps every outbound delivery attempt.
Add the acknowledged and abandoned statuses and the unattempted and
unobserved verification results.
A payload PostgreSQL cannot store as jsonb is kept as NULL while the
raw body stays, and NUL in stored errors and response bodies becomes
U+FFFD. receiveInbound() marks a log received once a queue worker
handles its activity.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
packages/models/README.md | 31 +-
.../migration.sql | 52 --
.../migration.sql | 96 ++++
.../snapshot.json | 531 +++++++++++++++++-
packages/models/src/activity-log.test.ts | 370 ++++++++++--
packages/models/src/activity-log.ts | 288 ++++++++--
packages/models/src/key.ts | 2 +-
packages/models/src/relations.ts | 37 +-
packages/models/src/schema.ts | 158 +++++-
9 files changed, 1386 insertions(+), 179 deletions(-)
delete mode 100644 packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
create mode 100644 packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql
rename packages/models/drizzle/{20260928123149_add_activity_logs_and_keys => 20260929224246_add_activity_logs_and_keys}/snapshot.json (84%)
diff --git a/packages/models/README.md b/packages/models/README.md
index c7daba4..fe5f8c8 100644
--- a/packages/models/README.md
+++ b/packages/models/README.md
@@ -34,12 +34,31 @@ Activity log storage
--------------------
`activity_logs` stores delivery observations separately from ActivityPub
-`activities`. `@drfed/models/activity-log` exposes `recordInbound`,
-`recordOutbound`, and `settleOutbound`.
+`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
+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/20260928123149_add_activity_logs_and_keys/migration.sql b/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
deleted file mode 100644
index 7a1c109..0000000
--- a/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/migration.sql
+++ /dev/null
@@ -1,52 +0,0 @@
-CREATE TYPE "activity_log_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint
-CREATE TYPE "activity_log_status" AS ENUM('received', 'unverified', 'rejected', 'queued', 'sent', 'failed', 'permanently_failed');--> 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,
- "type" text,
- "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,
- "status_code" integer,
- "error" text,
- "payload" jsonb NOT NULL,
- "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
- CONSTRAINT "activity_logs_direction_status_check" CHECK (("direction" = 'inbound' AND "status" IN ('received', 'unverified', 'rejected')) OR ("direction" = 'outbound' AND "status" IN ('queued', 'sent', 'failed', 'permanently_failed'))),
- 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)
-);
---> 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_instance_created_index" ON "activity_logs" ("instance_id","created" desc,"id" desc);--> statement-breakpoint
-CREATE INDEX "activity_log_actor_created_index" ON "activity_logs" ("actor_id","created" desc,"id" desc);--> 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
-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/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/20260928123149_add_activity_logs_and_keys/snapshot.json b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
similarity index 84%
rename from packages/models/drizzle/20260928123149_add_activity_logs_and_keys/snapshot.json
rename to packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
index d95c1cf..009c741 100644
--- a/packages/models/drizzle/20260928123149_add_activity_logs_and_keys/snapshot.json
+++ b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
@@ -1,7 +1,7 @@
{
"version": "8",
"dialect": "postgres",
- "id": "78cf5984-6dea-4b2c-b5c6-20591c77a1c4",
+ "id": "10a7ac3c-ffbe-46a9-8ea9-4f50ee544b12",
"prevIds": [
"478b925d-8ac8-466c-9804-bb055fdd6489",
"7c4a0afb-efbc-4ae7-b6b5-ebc6daaddb3e"
@@ -16,17 +16,38 @@
{
"values": [
"received",
+ "acknowledged",
"unverified",
"rejected",
"queued",
"sent",
"failed",
- "permanently_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",
@@ -81,6 +102,18 @@
"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",
@@ -346,6 +379,175 @@
"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,
@@ -411,6 +613,32 @@
"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,
@@ -424,6 +652,19 @@
"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,
@@ -528,6 +769,45 @@
"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,
@@ -541,6 +821,19 @@
"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,
@@ -557,7 +850,7 @@
{
"type": "jsonb",
"typeSchema": null,
- "notNull": true,
+ "notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
@@ -567,6 +860,19 @@
"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,
@@ -580,6 +886,19 @@
"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,
@@ -1980,22 +2299,50 @@
"nameExplicit": true,
"columns": [
{
- "value": "instance_id",
+ "value": "actor_id",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
- "value": "\"created\" desc",
+ "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": "\"id\" desc",
- "isExpression": true,
+ "value": "created",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "id",
+ "isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
@@ -2006,16 +2353,16 @@
"with": "",
"method": "btree",
"concurrently": false,
- "name": "activity_log_instance_created_index",
+ "name": "activity_log_attempt_log_index",
"entityType": "indexes",
"schema": "public",
- "table": "activity_logs"
+ "table": "activity_log_attempts"
},
{
"nameExplicit": true,
"columns": [
{
- "value": "actor_id",
+ "value": "instance_id",
"isExpression": false,
"asc": true,
"nullsFirst": false,
@@ -2041,7 +2388,28 @@
"with": "",
"method": "btree",
"concurrently": false,
- "name": "activity_log_actor_created_index",
+ "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"
@@ -2235,6 +2603,41 @@
"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": [
@@ -2309,6 +2712,45 @@
"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"],
@@ -2608,6 +3050,14 @@
"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,
@@ -2648,6 +3098,14 @@
"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,
@@ -2864,12 +3322,40 @@
"table": "accounts"
},
{
- "value": "(\"direction\" = 'inbound' AND \"status\" IN ('received', 'unverified', 'rejected')) OR (\"direction\" = 'outbound' AND \"status\" IN ('queued', 'sent', 'failed', 'permanently_failed'))",
+ "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",
@@ -2884,6 +3370,27 @@
"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",
diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts
index 34500d8..56f88b2 100644
--- a/packages/models/src/activity-log.test.ts
+++ b/packages/models/src/activity-log.test.ts
@@ -14,13 +14,16 @@
// 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
+// 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,
@@ -28,7 +31,7 @@ import {
import { observeKeyVersion } from "@drfed/models/key";
import { uuidV7 } from "@drfed/models/uuid";
import { PGlite } from "@electric-sql/pglite";
-import { eq } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { drizzle } from "drizzle-orm/pglite";
it("enforces log constraints, key retention and outbound state transitions", async () => {
@@ -49,28 +52,101 @@ it("enforces log constraints, key retention and outbound state transitions", asy
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
- .delete(schema.keyVersions)
- .where(eq(schema.keyVersions.id, version.id)),
+ .insert(schema.activityLogs)
+ .values({ ...inboundRow, verificationResult: null }),
);
await assert.rejects(
- db.delete(schema.keys).where(eq(schema.keys.id, version.keyId)),
+ 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: "inbound",
- status: "sent",
+ 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,
@@ -81,59 +157,171 @@ it("enforces log constraints, key retention and outbound state transitions", asy
}),
);
await assert.rejects(
- db.insert(schema.activityLogs).values({
- ...entry,
- id: uuidV7(),
- direction: "inbound",
- status: "received",
- statusCode: 600,
- }),
+ 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 = () =>
- db.query.activityLogs.findFirst({ where: { id: row.id } });
+ 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");
- await settleOutbound(db, {
- ...outgoing,
- status: "failed",
+ 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,
});
- await settleOutbound(db, { ...outgoing, status: "sent" });
- assert.equal((await current())?.status, "failed");
- await settleOutbound(db, {
- ...outgoing,
+ 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",
- statusCode: 410,
+ error: "Circuit breaker held activity expired.",
+ attempted: false,
});
- await settleOutbound(db, {
- ...outgoing,
+ 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: 503,
+ statusCode: 500,
+ error: "Failed:\nerror\u0000details",
+ responseBody: "error\u0000details",
+ attempted: true,
});
- assert.equal((await current())?.statusCode, 410);
- const second = await recordOutbound(db, {
- ...outgoing,
- activityIri: "https://local.example/activity/2",
- });
- await settleOutbound(db, {
+ 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: second.activityIri!,
- status: "sent",
- });
+ 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 db.query.activityLogs.findFirst({ where: { id: second.id } }))
- ?.status,
- "sent",
+ 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));
@@ -141,3 +329,107 @@ it("enforces log constraints, key retention and outbound state transitions", asy
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
index bd2aff1..72d317a 100644
--- a/packages/models/src/activity-log.ts
+++ b/packages/models/src/activity-log.ts
@@ -14,102 +14,274 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { and, eq, inArray, sql } from "drizzle-orm";
+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 type InboundLogEntry = LogEntry & {
- readonly status: "received" | "unverified" | "rejected";
+
+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;
};
-export type OutboundLogEntry = Omit & {
+
+/** `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()];
+}
+
/**
- * Persist one inbound observation without reserializing its original payload.
+ * 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,
- entry: InboundLogEntry,
+ { addressed, body, id = uuidV7(), ...entry }: InboundLogEntry,
): Promise {
- const [row] = await db
- .insert(activityLogs)
- .values({
+ return await insertLog(
+ db,
+ {
...entry,
- payload: entry.payload === null ? sql`'null'::jsonb` : entry.payload,
- id: uuidV7(),
+ body: Buffer.from(body),
+ payload: jsonb(entry.payload),
+ id,
direction: "inbound",
- })
- .returning();
- if (row == null) throw new Error("Missing inbound log after insertion.");
- return row;
+ },
+ inboundActorRows({ actorId: entry.actorId, addressed }),
+ );
}
/**
- * Start a delivery observation. Outbound keys are not verification keys.
+ * 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 {
- const [row] = await db
- .insert(activityLogs)
- .values({
+ return await insertLog(
+ db,
+ {
...entry,
- payload: entry.payload === null ? sql`'null'::jsonb` : entry.payload,
+ // 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",
- verificationKeyId: null,
- })
- .returning();
- if (row == null) throw new Error("Missing outbound log after insertion.");
- return row;
+ },
+ entry.actorId == null ? [] : [{ actorId: entry.actorId, sender: true }],
+ );
}
-/** Settle pending deliveries; successful or permanent results are never overwritten. */
+/** 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: {
- readonly activityIri: string;
- readonly inboxUrl: string;
- readonly status: "sent" | "failed" | "permanently_failed";
- readonly statusCode?: number | null;
- readonly error?: string | null;
- /** Restrict a synchronous completion to the row started by that invocation. */
- readonly id?: Uuid;
- /** Leave detailed failure callbacks intact when settling a thrown exception. */
- readonly onlyQueued?: boolean;
- },
-): Promise {
- await db
- .update(activityLogs)
- .set({
- status: entry.status,
- statusCode: entry.statusCode ?? null,
- error: entry.error ?? null,
- })
- .where(
- and(
- eq(activityLogs.direction, "outbound"),
- eq(activityLogs.activityIri, entry.activityIri),
- eq(activityLogs.inboxUrl, entry.inboxUrl),
- entry.id == null ? undefined : eq(activityLogs.id, entry.id),
- inArray(
- activityLogs.status,
- entry.status === "sent" || entry.onlyQueued
- ? ["queued"]
- : ["queued", "failed"],
+ 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/key.ts b/packages/models/src/key.ts
index b763891..d4dc83b 100644
--- a/packages/models/src/key.ts
+++ b/packages/models/src/key.ts
@@ -62,7 +62,7 @@ export function thumbprintInput(jwk: PublicJwk): string {
: key.kty === "OKP" && key.crv === "Ed25519"
? { crv: key.crv, kty: key.kty, x: key.x }
: null;
- // oxlint-able id-length
+ // oxlint-enable id-length
if (
required == null ||
Object.values(required).some(
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index 9913fb3..04a586b 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -39,11 +39,42 @@ export const relations = defineRelations(schema, (r) => ({
to: r.instances.id,
optional: false,
}),
- actor: r.one.actors({ from: r.activityLogs.actorId, to: r.actors.id }),
+ 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({
@@ -251,8 +282,8 @@ export const relations = defineRelations(schema, (r) => ({
},
actors: {
activityLogs: r.many.activityLogs({
- from: r.actors.id,
- to: r.activityLogs.actorId,
+ from: r.actors.id.through(r.activityLogActors.actorId),
+ to: r.activityLogs.id.through(r.activityLogActors.logId),
}),
collectionReferences: r.many.actorCollectionReferences({
from: r.actors.id,
diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts
index d721d4d..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,
@@ -520,16 +521,49 @@ 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(),
@@ -566,6 +600,11 @@ export const keyVersions = pgTable(
"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,
+ ),
],
);
@@ -578,32 +617,60 @@ export const activityLogs = pgTable(
.$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; consult status. */
+ /** 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().notNull(),
+ 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', 'unverified', 'rejected')) OR (${table.direction} = 'outbound' AND ${table.status} IN ('queued', 'sent', 'failed', 'permanently_failed'))`,
+ 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",
@@ -613,27 +680,102 @@ export const activityLogs = pgTable(
"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_created_index").on(
- table.actorId,
- 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];
From eed8fdf749e66bce3a83385e86c5e26e6759c4e2 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:24 +0900
Subject: [PATCH 04/13] Track what Fedify reports for each request
tracking.ts keeps, per request, the public key cache entries, spans,
measurements, and responses Fedify produces. telemetry.ts supplies the
tracer and meter providers that collect Fedify's documented spans,
events, and metrics, and reads response status codes from undici's
diagnostics_channel.
describe.ts derives log columns from an activity, keeping only IRIs
that parse as URLs and no NUL. addressing.ts finds the local actors an
activity addresses, including members of stored collections.
instanceUrl() composes the canonical URL of a path on an instance.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
packages/graphql/package.json | 1 +
.../graphql/src/activity-log/addressing.ts | 78 ++++
packages/graphql/src/activity-log/describe.ts | 110 ++++--
.../graphql/src/activity-log/telemetry.ts | 346 ++++++++++++++++++
packages/graphql/src/activity-log/tracking.ts | 264 +++++++++++++
packages/graphql/src/origin.ts | 12 +
pnpm-lock.yaml | 3 +
7 files changed, 788 insertions(+), 26 deletions(-)
create mode 100644 packages/graphql/src/activity-log/addressing.ts
create mode 100644 packages/graphql/src/activity-log/telemetry.ts
create mode 100644 packages/graphql/src/activity-log/tracking.ts
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index e4d33d1..097f42c 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -130,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/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
index ac1c778..5f7ff1c 100644
--- a/packages/graphql/src/activity-log/describe.ts
+++ b/packages/graphql/src/activity-log/describe.ts
@@ -14,54 +14,112 @@
// 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 } from "@fedify/vocab";
+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)
: {};
}
-const string = (value: unknown): string | null =>
- typeof value === "string" ? value : null;
+/**
+ * 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;
/**
- * Describe JSON-LD without dereferencing its actor or object.
- * @returns Best-effort activity metadata.
+ * 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 async function describeActivity(
+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: Parameters[1] = {},
-) {
- let value = document(payload);
+ 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);
- if (!(activity instanceof Activity)) {
- throw new TypeError("Expected an ActivityStreams activity.");
- }
- value = document(
- await activity.toJsonLd({ ...options, format: "compact" }),
- );
+ 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(value.type),
- activityIri: string(value.id),
- remoteActorIri: string(value.actor),
+ type: string(raw.type),
+ types,
+ activityIri: iri(raw.id),
+ remoteActorIri: iri(raw.actor),
objectType: null,
objectIri: null,
};
}
- const object = document(value.object);
+ const value = document(
+ await activity.toJsonLd({ ...options, format: "compact" }),
+ );
+ const object = document(first(value.object));
return {
- type: string(value.type),
- activityIri: string(value.id),
- remoteActorIri: string(value.actor) ?? string(document(value.actor).id),
- objectType: string(object.type),
- objectIri: string(value.object) ?? string(object.id),
+ 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.
@@ -70,6 +128,6 @@ export function remoteHost(
actorIri: string | null,
keyIri: string | null,
): string | null {
- const iri = actorIri ?? keyIri;
- return iri != null && URL.canParse(iri) ? new URL(iri).host || null : null;
+ const url = iri(actorIri ?? keyIri);
+ return url == null ? null : new URL(url).host || null;
}
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/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/pnpm-lock.yaml b/pnpm-lock.yaml
index 2eea2ce..2617ce7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -192,6 +192,9 @@ importers:
'@logtape/logtape':
specifier: 'catalog:'
version: 2.3.0-dev.840
+ '@opentelemetry/api':
+ specifier: ^1.9.1
+ version: 1.9.1
'@pothos/core':
specifier: ^4.13.0
version: 4.13.0(graphql@16.14.2)
From 3bfe1cc268008b7fb99be880ea62911eaa22541e Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:25 +0900
Subject: [PATCH 05/13] Settle outbound logs from the outbox queue
Wrap the outbox queue so that every attempt of Fedify's worker settles
its log and adds an attempt. Retries and exhaustion come from the
activitypub.outbox.activity metric, and the queue reports nativeRetrial
as false so that every retry goes through Fedify. Log IDs travel on
queue messages, so repeated deliveries settle their own rows.
deliverActivity() strips bto and bcc, skips recipients without an id as
Fedify does, and counts a delivery as sent only on an
activitypub.activity.sent event; one Fedify never sent or enqueued
settles as permanently_failed. The status code follows redirects from
the inbox to the final response. The tests run Fedify's own delivery
against a local inbox server.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
.../graphql/src/activity-log/outbound.test.ts | 324 +++++++++++
packages/graphql/src/activity-log/outbound.ts | 184 ++++---
.../graphql/src/activity-log/queue.test.ts | 379 +++++++++++++
packages/graphql/src/activity-log/queue.ts | 509 ++++++++++++++++++
.../graphql/src/activity-log/remote.test.ts | 75 +++
5 files changed, 1397 insertions(+), 74 deletions(-)
create mode 100644 packages/graphql/src/activity-log/outbound.test.ts
create mode 100644 packages/graphql/src/activity-log/queue.test.ts
create mode 100644 packages/graphql/src/activity-log/queue.ts
create mode 100644 packages/graphql/src/activity-log/remote.test.ts
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..63ffe7b
--- /dev/null
+++ b/packages/graphql/src/activity-log/outbound.test.ts
@@ -0,0 +1,324 @@
+// 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(),
+ ...(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
index bce4d82..6c62419 100644
--- a/packages/graphql/src/activity-log/outbound.ts
+++ b/packages/graphql/src/activity-log/outbound.ts
@@ -18,71 +18,82 @@ 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 Context,
- type OutboxErrorHandler,
- type OutboxPermanentFailureHandler,
- SendActivityError,
-} from "@fedify/fedify";
-import type { Activity, Recipient } from "@fedify/vocab";
+ 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>();
+
/**
- * Record HTTP failures without making diagnostic persistence affect delivery.
- * @returns A Fedify outbox error callback.
+ * How a delivery Fedify returned from without sending or enqueuing settles:
+ * no attempt was made, and none will be.
*/
-export function createOutboxErrorHandler(db: Database): OutboxErrorHandler {
- return async (error, activity) => {
- if (!(error instanceof SendActivityError) || activity?.id == null) return;
- try {
- await settleOutbound(db, {
- activityIri: activity.id.href,
- inboxUrl: error.inbox.href,
- status: "failed",
- statusCode: error.statusCode,
- error: error.responseBody,
- });
- } catch (cause) {
- logger.error("Could not record delivery failure: {error}", {
- error: cause,
- });
- }
- };
+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);
}
/**
- * Preserve permanent failures even if the general error callback follows them.
- * @returns A Fedify permanent-failure callback.
+ * 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 createPermanentFailureHandler(
- db: Database,
-): OutboxPermanentFailureHandler {
- return async (_ctx, values) => {
- if (values.activity.id == null) return;
- try {
- await settleOutbound(db, {
- activityIri: values.activity.id.href,
- inboxUrl: values.inbox.href,
- status: "permanently_failed",
- statusCode: values.statusCode,
- error: values.error.responseBody,
- });
- } catch (error) {
- logger.error("Could not record permanent delivery failure: {error}", {
- error,
- });
+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 synchronous federation.
- * Each activity needs a unique IRI. Queue-backed delivery is not supported.
+ * 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(
@@ -108,65 +119,90 @@ export async function deliverActivity(
if (actor == null) {
throw new TypeError("Delivery requires a local sender on this instance.");
}
- const targets = new Map();
- for (const recipient of Array.isArray(recipients)
- ? recipients
- : [recipients]) {
- if (recipient.inboxId == null) continue;
- const url = recipient.inboxId.href;
- targets.set(url, [...(targets.get(url) ?? []), recipient]);
- }
+ 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 activity.toJsonLd({
+ const payload = await delivered.toJsonLd({
format: "compact",
contextLoader: ctx.contextLoader,
});
const description = await describeActivity(payload, {
contextLoader: ctx.contextLoader,
});
- const recipient = group[0]!;
+ 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: activity.id!.href,
- remoteActorIri: recipient.id?.href ?? null,
- remoteHost: remoteHost(recipient.id?.href ?? null, inboxUrl),
+ activityIri,
+ remoteActorIri,
+ remoteHost: remoteHost(remoteActorIri, inboxUrl),
inboxUrl,
+ recipientIris,
payload,
});
} catch (error) {
logger.error("Could not record outgoing activity: {error}", { error });
}
- const settle = async (status: "sent" | "failed", error?: unknown) => {
+ const settle = async (settlement: Settlement) => {
if (row == null) return;
try {
await settleOutbound(db, {
+ ...settlement,
id: row.id,
- activityIri: activity.id!.href,
+ activityIri,
inboxUrl,
- status,
- onlyQueued: true,
- error: error == null ? null : String(error),
});
- } catch (cause) {
+ } catch (error) {
logger.error("Could not settle outgoing activity: {error}", {
- error: cause,
+ error,
});
}
};
- try {
- // Resolve each destination independently: one failing inbox must not mark
- // a successful delivery as failed or leave it queued.
- await ctx.sendActivity(sender, group, activity);
- } catch (error) {
- await settle("failed", 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);
}
- await settle("sent");
}),
);
const failure = results.find((result) => result.status === "rejected");
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..9c01dad
--- /dev/null
+++ b/packages/graphql/src/activity-log/queue.test.ts
@@ -0,0 +1,379 @@
+// 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(),
+ ...(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 either follower 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 reads them back as Latin-1 and
+ // `fetch()` as UTF-8, and each requests the path it read.
+ 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",
+ algorithm === "Ed25519" ? "/caf%C3%A9" : "/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();
+ }
+}
From cf99740859a9592efef456f619a7eb364bddcc99 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:25 +0900
Subject: [PATCH 06/13] Record inbound verification as Fedify did it
The recorder no longer verifies requests itself. Verdicts come from
Fedify's verification spans and the
activitypub.signature.verification.duration metric, and the key from
the cache entries of that verification's own key fetches, so the
stored key version is the one Fedify used. A key that could not be
fetched is recorded as key_fetch_error with the reason, and a verified
proof whose actor Fedify refused as verified but rejected.
A log's created time is when the request arrived. Requests whose
handling throws are logged before the error is rethrown.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
.../graphql/src/activity-log/inbound.test.ts | 1725 +++++++++++++++++
packages/graphql/src/activity-log/inbound.ts | 364 ++--
packages/graphql/src/activity-log/keycache.ts | 54 +-
.../graphql/src/activity-log/verification.ts | 388 ++++
4 files changed, 2371 insertions(+), 160 deletions(-)
create mode 100644 packages/graphql/src/activity-log/inbound.test.ts
create mode 100644 packages/graphql/src/activity-log/verification.ts
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
index a82db72..d7e6660 100644
--- a/packages/graphql/src/activity-log/inbound.ts
+++ b/packages/graphql/src/activity-log/inbound.ts
@@ -16,115 +16,77 @@
import type { Database } from "@drfed/models";
import { recordInbound } from "@drfed/models/activity-log";
-import { observeKeyVersion } from "@drfed/models/key";
-import { type Uuid, validateUuid } from "@drfed/models/uuid";
-import {
- type Federation,
- type FederationFetchOptions,
- type KeyCache,
- type KvKey,
- type KvStore,
- type RequestContext,
- type VerifyRequestDetailedResult,
- exportJwk,
- verifyRequestDetailed,
-} from "@fedify/fedify";
+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 } from "../origin.ts";
-import { describeActivity, remoteHost } from "./describe.ts";
-import { createKeyCache } from "./keycache.ts";
+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(
- verification: VerifyRequestDetailedResult,
- responseStatus: number,
-): "received" | "unverified" | "rejected" {
- if (responseStatus >= 200 && responseStatus < 300) return "received";
- return verification.verified ? "rejected" : "unverified";
-}
-
-function signedKeyId(
- verification: VerifyRequestDetailedResult | undefined,
-): URL | null {
- if (verification == null) return null;
- if (verification.verified) return verification.key.id;
- return verification.reason.type === "noSignature"
- ? null
- : (verification.reason.keyId ?? null);
+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";
}
-function verificationError(result: VerifyRequestDetailedResult): string | null {
- if (result.verified) return null;
- const { reason } = result;
- if (reason.type !== "keyFetchError") return reason.type;
- return `keyFetchError: ${
- "status" in reason.result ? reason.result.status : reason.result.error.name
- }`;
+/**
+ * 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];
+ });
}
-type Loaders = Pick<
- RequestContext,
- "documentLoader" | "contextLoader"
->;
-
-async function observeVerification(
- db: Database,
- request: Request,
- keyCache: KeyCache,
- loaders: Loaders,
-) {
- const observedKeys = new Map>>();
- const observedCache: KeyCache = {
- ...keyCache,
- async get(id) {
- const key = await keyCache.get(id);
- observedKeys.set(id.href, key);
- return key;
- },
- async set(id, key) {
- observedKeys.set(id.href, key);
- await keyCache.set(id, key);
- },
- };
- let result: VerifyRequestDetailedResult | undefined;
- let keyId: Uuid | null = null;
- // Capture key material before Fedify or
- // another request can refresh the cache.
+/**
+ * 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 {
- result = await verifyRequestDetailed(request, {
- keyCache: observedCache,
- ...loaders,
- });
- const key = result.verified
- ? result.key
- : result.reason.type === "invalidSignature" && result.reason.keyId != null
- ? observedKeys.get(result.reason.keyId.href)
- : null;
- const verifiedKeyId = result.verified
- ? result.key.id
- : result.reason.type === "noSignature"
- ? null
- : result.reason.keyId;
- if (key?.publicKey != null && verifiedKeyId != null) {
- keyId = (
- await observeKeyVersion(db, {
- iri: verifiedKeyId.href,
- publicKey: await exportJwk(key.publicKey),
- })
- ).id;
- }
- } catch (error) {
- logger.error("Could not observe inbox verification: {error}", {
- error,
- });
+ const value: unknown = JSON.parse(new TextDecoder().decode(body));
+ return value;
+ } catch {
+ return undefined;
}
- return { result, keyId };
}
async function findRecordingInstance(db: Database, host: string) {
@@ -143,94 +105,196 @@ async function findRecordingInstance(db: Database, host: string) {
}
}
+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,
- kv,
- publicKeyPrefix,
+ rootOrigin,
}: {
readonly db: Database;
- readonly federation: Federation;
- readonly kv: KvStore;
- readonly publicKeyPrefix?: KvKey;
+ 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 payload: unknown = await request
- .clone()
- .json()
- .catch((e) => e);
- const response = await federation.fetch(request, options);
- if (
- request.method !== "POST" ||
- route?.type !== "inbox" ||
- // Unclaimed subdomains must not create orphaned public-key history.
- instance == null ||
- payload instanceof Error
- ) {
- return response;
+ 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 keyCache = createKeyCache(kv, publicKeyPrefix, loaders);
- const verification = await observeVerification(
- db,
- request,
- keyCache,
- loaders,
+ 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 {
- const actor =
- route.identifier != null && validateUuid(route.identifier)
- ? await db.query.actors.findFirst({
- where: {
- id: route.identifier,
- instanceId: instance.id,
- localId: { isNotNull: true },
- },
- })
- : null;
- const signedKeyIri = signedKeyId(verification.result)?.href ?? null;
- const description = await describeActivity(payload, loaders);
- await recordInbound(db, {
- ...description,
- instanceId: instance.id,
- actorId: actor?.id ?? null,
- status:
- verification.result == null
- ? response.ok
- ? "received"
- : "unverified"
- : classifyInbound(verification.result, response.status),
- signedKeyIri,
- verificationKeyId: verification.keyId,
- remoteHost: remoteHost(description.remoteActorIri, signedKeyIri),
- inboxUrl: request.url,
- statusCode: response.status,
- error:
- verification.result == null
- ? "Verification observation failed"
- : verificationError(verification.result),
+ 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 response;
+ return unwrap(outcome);
},
};
}
diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts
index 65505d9..86411b3 100644
--- a/packages/graphql/src/activity-log/keycache.ts
+++ b/packages/graphql/src/activity-log/keycache.ts
@@ -22,6 +22,8 @@ import type {
} 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;
@@ -32,6 +34,21 @@ type DiagnosticKeyCache = KeyCache & {
};
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.
@@ -46,16 +63,9 @@ export function createKeyCache(
async get(keyId) {
const value = await kv.get([...prefix, keyId.href]);
if (value == null) return value;
- try {
- return await CryptographicKey.fromJsonLd(value, options);
- } catch {
- try {
- return await Multikey.fromJsonLd(value, options);
- } catch {
- await kv.delete([...prefix, keyId.href]);
- return undefined;
- }
- }
+ const key = await parseKey(value, options);
+ if (key == null) await kv.delete([...prefix, keyId.href]);
+ return key;
},
async set(keyId, key) {
await kv.set(
@@ -110,3 +120,27 @@ export function createKeyCache(
},
};
}
+
+/**
+ * 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([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/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)}`,
+ };
+ }
+}
From 7205795e33f82a6761408524a977acc2d7164fbc Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:25 +0900
Subject: [PATCH 07/13] Expose activity log details through GraphQL
ActivityLog exposes the verification mechanism and result, the raw
request, the response body, the completion time, and its attempts.
Actor.activityLogs goes through activity_log_actors, and Key.versions
becomes a connection. Restore the import order of schema.ts.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
packages/graphql/src/activity-log.test.ts | 288 +++++++++++++-------
packages/graphql/src/activity-log/entry.ts | 302 +++++++++++++++++++--
packages/graphql/src/key.ts | 9 +-
packages/graphql/src/schema.ts | 9 +-
4 files changed, 487 insertions(+), 121 deletions(-)
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
index 929389b..bc0540f 100644
--- a/packages/graphql/src/activity-log.test.ts
+++ b/packages/graphql/src/activity-log.test.ts
@@ -24,32 +24,35 @@ import {
classifyInbound,
createInboundRecorder,
createKeyCache,
- createOutboxErrorHandler,
- createPermanentFailureHandler,
deliverActivity,
describeActivity,
} from "@drfed/graphql/activity-log";
-import createFederation from "@drfed/graphql/federation";
+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,
- type Federation,
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 } from "drizzle-orm";
+import { eq, isNull } from "drizzle-orm";
+import { withInbox } from "./activity-log/remote.test.ts";
import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts";
import {
accountId,
@@ -67,6 +70,13 @@ 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}`,
@@ -115,7 +125,7 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
contextLoaderFactory: () => contextLoader,
documentLoaderFactory: () => documentLoader,
});
- const recorder = createInboundRecorder({ db, federation, kv });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
const send = async (
body: unknown,
privateKey = a.privateKey,
@@ -128,6 +138,14 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
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);
@@ -153,6 +171,18 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
});
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,
+ );
const firstLoads = loads;
assert.equal(firstLoads, 1);
assert.equal((await send(payload("second"))).status, 202);
@@ -178,20 +208,20 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
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);
- assert.equal(
- (
- await db.query.activityLogs.findFirst({
- where: { activityIri: mismatch.id },
- })
- )?.status,
- "rejected",
- );
+ 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(
@@ -215,7 +245,7 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
});
});
-it("records missing signatures and failed key fetches, and skips non-JSON/non-inbox requests", async () => {
+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();
@@ -228,7 +258,7 @@ it("records missing signatures and failed key fetches, and skips non-JSON/non-in
documentLoaderFactory: () => () =>
Promise.reject(new TypeError("offline")),
});
- const recorder = createInboundRecorder({ db, federation, kv });
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
assert.equal(
(await recorder.fetch(request(payload("unsigned")), fetchOptions)).status,
401,
@@ -238,6 +268,8 @@ it("records missing signatures and failed key fetches, and skips non-JSON/non-in
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(
(
@@ -257,23 +289,42 @@ it("records missing signatures and failed key fetches, and skips non-JSON/non-in
});
assert.equal(failed?.signedKeyIri, keyId.href);
assert.equal(failed?.verificationKeyId, null);
+ assert.equal(failed?.verificationResult, "key_fetch_error");
assert.match(failed?.error ?? "", /keyFetchError/u);
- await recorder.fetch(
+ 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), 2);
+ assert.equal(await db.$count(schema.activityLogs), 3);
await recorder.fetch(request(null), fetchOptions);
- const nullPayload = await db.query.activityLogs.findFirst({
- where: { type: { isNull: true } },
- });
- assert.equal(nullPayload?.payload, null);
- await db.execute(`DROP TABLE activity_logs`);
+ 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,
@@ -319,6 +370,7 @@ it("uses the Fedify KV serialization for RSA, Multikey and negative entries", as
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,
@@ -328,10 +380,22 @@ it("classifies accepted proofs independently of HTTP signature failure and descr
(await describeActivity({ type: "Extension", id: "urn:test" })).type,
"Extension",
);
- assert.equal(
- classifyInbound({ verified: false, reason: { type: "noSignature" } }, 202),
- "received",
- );
+ 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,
@@ -342,6 +406,7 @@ it("classifies accepted proofs independently of HTTP signature failure and descr
}),
{
type: "Extension",
+ types: ["Extension"],
activityIri: "urn:test",
remoteActorIri: null,
objectType: null,
@@ -363,6 +428,7 @@ it("paginates tied timestamps, filters logs and reads verification keys as an in
for (const status of ["received", "unverified", "rejected"] as const) {
rows.push(
await recordInbound(db, {
+ ...observed,
instanceId: localInstanceId,
actorId: localActorId,
inboxUrl: inbox,
@@ -385,7 +451,7 @@ it("paginates tied timestamps, filters logs and reads verification keys as an in
});
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 { uuid } } } } }
+ edges { cursor node { uuid direction status type payload verificationKey { fingerprint publicKey key { iri versions { edges { node { uuid } } } } } } }
pageInfo { hasNextPage endCursor }
} } }
}`;
@@ -445,6 +511,7 @@ it("denies anonymous/nonmember access including node typename and remote connect
await seedLocalActor(db);
await seedRemoteActor(db);
const log = await recordInbound(db, {
+ ...observed,
instanceId: localInstanceId,
actorId: localActorId,
inboxUrl: inbox,
@@ -494,19 +561,22 @@ it("denies anonymous/nonmember access including node typename and remote connect
});
});
+/**
+ * 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() });
const ctx = federation.createContext(new URL(inbox), undefined);
- const recipient = {
- id: actorIri,
- inboxId: new URL("https://remote.example/inbox"),
- };
- const activity = new Create({
- id: new URL("https://test-instance.drfed.org/activity/1"),
- actor: ctx.getActorUri(localActorId),
- });
+ const key = await testKey();
const sender = { identifier: localActorId };
const fakeContext = (
sendActivity: Context["sendActivity"],
@@ -518,15 +588,39 @@ it("settles synchronous delivery and retains HTTP failure diagnostics", async ()
: Reflect.get(target, property);
},
});
- await deliverActivity(
- db,
- fakeContext(() => Promise.resolve()),
- sender,
- [recipient, recipient],
- activity,
- );
+ // 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);
- assert.equal((await db.query.activityLogs.findFirst())?.status, "sent");
+ 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,
@@ -537,29 +631,33 @@ it("settles synchronous delivery and retains HTTP failure diagnostics", async ()
id: new URL("https://test-instance.drfed.org/activity/2"),
actor: ctx.getActorUri(localActorId),
});
- await deliverActivity(
- db,
- fakeContext(async () => {
- await createOutboxErrorHandler(db)(failure, failActivity);
- await createPermanentFailureHandler(db)(ctx, {
- activity: failActivity,
- inbox: recipient.inboxId,
- error: failure,
- statusCode: 410,
- reason: "http",
- actorIds: [actorIri],
- });
- }),
- sender,
- recipient,
- failActivity,
+ 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, "permanently_failed");
+ assert.equal(failed?.status, "failed");
assert.equal(failed?.statusCode, 410);
- assert.equal(failed?.error, "Gone forever");
+ 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"]],
+ );
});
});
@@ -568,10 +666,7 @@ it("settles successful inboxes independently from thrown delivery failures", asy
await seedLocalActor(db);
const federation = await createFederation(db, { kv: new MemoryKvStore() });
const context = federation.createContext(new URL(inbox), undefined);
- const good = {
- id: actorIri,
- inboxId: new URL("https://remote.example/good"),
- };
+ const key = await testKey();
const bad = {
id: actorIri,
inboxId: new URL("https://remote.example/bad"),
@@ -583,37 +678,41 @@ it("settles successful inboxes independently from thrown delivery failures", asy
const ctx = new Proxy(context, {
get(target, property) {
if (property !== "sendActivity") return Reflect.get(target, property);
- return async (_sender: unknown, recipients: { inboxId: URL }[]) => {
- if (recipients[0]?.inboxId.href === bad.inboxId.href) {
- const error = new SendActivityError(
- bad.inboxId,
- 503,
- "unavailable",
- "Try later",
- );
- await createOutboxErrorHandler(db)(error, activity);
- throw error;
- }
- };
+ 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 assert.rejects(
- deliverActivity(
- db,
- ctx,
- { identifier: localActorId },
- [good, bad],
- activity,
- ),
- SendActivityError,
- );
+ 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, "failed");
- assert.equal(rows[0]?.statusCode, 503);
- assert.equal(rows[0]?.error, "Try later");
- assert.equal(rows[1]?.status, "sent");
+ 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");
});
});
@@ -621,6 +720,7 @@ 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",
@@ -666,8 +766,8 @@ it("skips verification observations for unclaimed hosts and failed instance look
});
},
fetch: () => Promise.resolve(passThrough),
- } as unknown as Federation;
- const recorder = createInboundRecorder({ db, federation, kv });
+ } as unknown as TrackedFederation;
+ const recorder = createInboundRecorder({ db, federation, rootOrigin });
const signed = await signRequest(
request(payload("unknown-host")),
pair.privateKey,
diff --git a/packages/graphql/src/activity-log/entry.ts b/packages/graphql/src/activity-log/entry.ts
index ab7116b..54a354c 100644
--- a/packages/graphql/src/activity-log/entry.ts
+++ b/packages/graphql/src/activity-log/entry.ts
@@ -17,6 +17,8 @@
import {
activityLogDirectionEnum,
activityLogStatusEnum,
+ activityLogVerificationMechanismEnum,
+ activityLogVerificationResultEnum,
} from "@drfed/models/schema";
import type { Uuid } from "@drfed/models/uuid";
import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle";
@@ -24,19 +26,116 @@ import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle";
import builder, { type DrFedObjectRef } from "../builder.ts";
export { createKeyCache } from "./keycache.ts";
-export { classifyInbound, createInboundRecorder } from "./inbound.ts";
+export {
+ classifyInbound,
+ createInboundRecorder,
+ parseBody,
+ recordedHeaders,
+} from "./inbound.ts";
export { describeActivity } from "./describe.ts";
export {
- deliverActivity,
- createOutboxErrorHandler,
- createPermanentFailureHandler,
-} from "./outbound.ts";
+ 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", {
- values: activityLogStatusEnum.enumValues,
+ 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) => ({
@@ -45,11 +144,50 @@ const ActivityLogFilter = builder.inputType("ActivityLogFilter", {
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 } } } },
@@ -59,34 +197,153 @@ const ActivityLogRef = builder.drizzleNode("activityLogs", {
fields: (t) => ({
uuid: t.expose("id", { type: "UUID" }),
instance: t.relation("instance"),
- actor: t.relation("actor", { nullable: true }),
+ 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 }),
- type: t.exposeString("type", { nullable: true }),
+ 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 }),
+ 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 used in verification, even when it failed. " +
- "Its presence does not imply success; consult status.",
+ "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" }),
- statusCode: t.exposeInt("statusCode", { nullable: true }),
- error: t.exposeString("error", { 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:
- "Original inbound JSON or compact outbound JSON-LD. " +
- "May contain unverified remote input and private recipients. " +
- "Null represents a literal JSON null body.",
+ "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`.",
}),
- created: t.expose("created", { type: "DateTime" }),
}),
});
export const ActivityLog: DrFedObjectRef = ActivityLogRef;
@@ -129,8 +386,11 @@ builder.drizzleObjectField("actors", "activityLogs", (t) =>
type: ActivityLog,
args: { filter: t.arg({ type: ActivityLogFilter }) },
description:
- "This local actor's delivery observations, newest first. " +
- "Restricted to instance members and administrators.",
+ "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 },
diff --git a/packages/graphql/src/key.ts b/packages/graphql/src/key.ts
index 9c5ea1c..974e81d 100644
--- a/packages/graphql/src/key.ts
+++ b/packages/graphql/src/key.ts
@@ -18,6 +18,9 @@ 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 },
@@ -25,7 +28,8 @@ const KeyRef = builder.drizzleNode("keys", {
uuid: t.expose("id", { type: "UUID" }),
iri: t.expose("iri", { type: "URL" }),
created: t.expose("created", { type: "DateTime" }),
- versions: t.relation("versions", {
+ versions: t.relatedConnection("versions", {
+ description: "The observed versions of the key, oldest first.",
query: { orderBy: { firstSeen: "asc", id: "asc" } },
}),
}),
@@ -36,6 +40,9 @@ const observationDescription =
"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 },
diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts
index 04cb2c4..0bdac2f 100644
--- a/packages/graphql/src/schema.ts
+++ b/packages/graphql/src/schema.ts
@@ -14,14 +14,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
// oxlint-disable import/no-unassigned-import
-
import "./account.ts";
-import "./activity-log/entry.ts";
-import "./actor.ts";
-import "./auth/entry.ts";
import "./instance.ts";
-import "./key.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({});
From c0934239c10136c433d9bc5a9dbdbe1795a55780 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 12:27:25 +0900
Subject: [PATCH 08/13] Wire activity logging into federation and server
createFederation() hands Fedify the tracking KV store and the tracer
and meter providers, wraps the outbox queue, and returns a
TrackedFederation, the only federation createInboundRecorder()
accepts. Inbox listeners call markHandled(), which tells a received
activity from an acknowledged duplicate. The recorder takes the root
origin instead of the KV store. Document the contract.
https://github.com/fedify-dev/drfed/issues/12
Claude Code wrote this change from Codex reviews of the branch against
the issue, as directed by the contributor.
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Codex:gpt-6-astra
---
CONTRIBUTING.md | 19 +++--
packages/drfed/src/index.ts | 2 +-
packages/drfed/src/serving.test.ts | 2 +-
packages/graphql/README.md | 112 ++++++++++++++++++++++++-----
packages/graphql/src/federation.ts | 68 +++++++++++-------
5 files changed, 152 insertions(+), 51 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f7ff936..06dcffc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -363,13 +363,18 @@ 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 the same KV store as Fedify. Keep the
-public-key cache serialization compatible with the installed Fedify version.
-Use `deliverActivity` for future outgoing delivery; queue-backed delivery is
-not supported until delivery success callbacks are available. Log payloads
-are private to local instance members and administrators, including Relay node
-lookups.
+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/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index 9df75f9..6f08cdc 100644
--- a/packages/drfed/src/index.ts
+++ b/packages/drfed/src/index.ts
@@ -65,7 +65,7 @@ async function runServer(options: ServerOptions) {
federation: createInboundRecorder({
db: options.drizzle.db,
federation,
- kv,
+ rootOrigin,
}),
rootOrigin,
serveControlSurface: yogaServer.fetch,
diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts
index 6cfa953..696e159 100644
--- a/packages/drfed/src/serving.test.ts
+++ b/packages/drfed/src/serving.test.ts
@@ -264,7 +264,7 @@ it("records inbox requests only on the instance surface", async () => {
const federation = await createFederation(db, { kv });
const fetch = createFetchHandler({
rootOrigin,
- federation: createInboundRecorder({ db, federation, kv }),
+ federation: createInboundRecorder({ db, federation, rootOrigin }),
serveControlSurface: () => new Response("control"),
});
const body = JSON.stringify({
diff --git a/packages/graphql/README.md b/packages/graphql/README.md
index 45f2522..357af9e 100644
--- a/packages/graphql/README.md
+++ b/packages/graphql/README.md
@@ -27,7 +27,7 @@ import { createYogaServer } from "@drfed/graphql";
import createFederation, { createInboundRecorder } from "@drfed/graphql/federation";
const federation = await createFederation(db, { kv });
-const recordedInbox = createInboundRecorder({ db, federation, kv });
+const recordedInbox = createInboundRecorder({ db, federation, rootOrigin });
const yoga = createYogaServer(db, federation, {
loginOrigins: new Set(["https://drfed.example.com"]),
});
@@ -48,22 +48,100 @@ Activity logs
-------------
`Instance.activityLogs` and `Actor.activityLogs` expose delivery observations,
-newest first, with direction, status, and type filters. Only accepted local
+newest first, with direction, status, and type filters. Only accepted local
instance members and site administrators can read them, including through
-Relay node IDs. Payloads may contain unverified input and private recipients.
-A literal JSON `null` body is retained and exposed as a nullable `payload`.
-
-`ActivityLog.verificationKey` retains the observed public key version even
-when verification failed. `KeyVersion.firstSeen` and `lastSeen` are DrFed
-observation times, not remote rotation times or evidence of continuous use.
-
-Wrap the federation HTTP surface with `createInboundRecorder` using the same
-KV store (and public-key prefix when customized). JSON inbox POSTs are logged;
-non-JSON bodies are excluded. Logging errors never replace federation responses.
+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 records one row per destination inbox
-and settles each synchronous delivery independently. `createFederation` rejects
-queues until Fedify exposes delivery success callbacks. Activity resource
-persistence (#88), retention policies, and the activity-log UI (#13) are
-separate.
+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/src/federation.ts b/packages/graphql/src/federation.ts
index 6b042af..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,15 +48,27 @@ 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 {
- createOutboxErrorHandler,
- createPermanentFailureHandler,
-} from "./activity-log/outbound.ts";
+ 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";
/**
@@ -130,7 +141,7 @@ export function buildFederation(db: Database): FederationBuilder {
}
return toActorObject(ctx, identifier, actor);
})
- // Until #87 supplies keys, inbox loaders use unsigned document fetching.
+ // FIXME: https://github.com/fedify-dev/drfed/issues/87
.setKeyPairsDispatcher(() => [])
.mapHandle(async (ctx, username) => {
const actor = await db.query.actors.findFirst({
@@ -149,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) => {
@@ -321,7 +333,7 @@ export function buildFederation(db: Database): FederationBuilder {
};
},
);
- builder.setOutboxPermanentFailureHandler(createPermanentFailureHandler(db));
+ builder.setOutboxPermanentFailureHandler(reportPermanentFailure);
return builder;
}
@@ -329,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.
@@ -337,20 +353,30 @@ export function buildFederation(db: Database): FederationBuilder {
export default async function createFederation(
db: Database,
options: FederationOptions,
-): Promise> {
- if (options.queue != null) {
- throw new TypeError(
- "Activity logging requires synchronous delivery; queues are not supported.",
- );
- }
- const recordError = createOutboxErrorHandler(db);
- return await buildFederation(db).build({
+): 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 recordError(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
@@ -486,22 +512,14 @@ function recipients(rows: readonly StoredAddressing[]): {
* @returns An EXISTS predicate matching explicit Public addressing.
*/
function publicAddressing(sourceId: SQLWrapper): SQL {
- return sql`exists (select 1 from ${schema.addressing} where ${
- schema.addressing.sourceId
- } = ${sourceId} and ${
- schema.addressing.targetId
- } = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
+ return sql`exists (select 1 from ${schema.addressing} where ${schema.addressing.sourceId} = ${sourceId} and ${schema.addressing.targetId} = ${PUBLIC_RESOURCE_ID} and ${schema.addressing.property} in ('to', 'cc'))`;
}
function servedActivity(table: {
id: SQLWrapper;
objectId: SQLWrapper;
type: SQLWrapper;
}): SQL {
- return sql`${table.type} = 'Create' and ${publicAddressing(
- table.id,
- )} and exists (select 1 from ${schema.objects} where ${
- schema.objects.id
- } = ${table.objectId} and ${schema.objects.deleted} is null)`;
+ return sql`${table.type} = 'Create' and ${publicAddressing(table.id)} and exists (select 1 from ${schema.objects} where ${schema.objects.id} = ${table.objectId} and ${schema.objects.deleted} is null)`;
}
function collectionIri(actor: StoredActor, role: string): URL | null {
const reference = actor.collectionReferences.find(
From f8bab57d0c9614b983dce2cf9ed9aca0b1f3230d Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 14:20:10 +0900
Subject: [PATCH 09/13] Upgrade Fedify to 2.4.0
Fedify 2.4.0 changes several behaviors DrFed relies on:
- Private addresses are refused for outbound delivery too, redirects
included. drfed-server allows them, so that it can deliver to apps
under development on a local network, and so do the tests that
deliver to a local inbox. createFederation() leaves the option to
its callers, since Fedify refuses it together with the loader
factories the inbound tests pass.
- KvKeyCache keeps each public key under the generation segment "2"
and with a 30-day TTL. createKeyCache() and trackedKey() follow
that layout, so the key a verification used is recorded again.
- Object tombstones are served with HTTP 410.
- Fedify fetches the actor on every inbox request to check that it
owns the key, so the cache test counts only fetches of the key.
- Fedify follows every redirect itself, signed or not, reading a
Location outside ASCII as Latin-1.
The contributor bumped the Fedify catalog entries, allowed private
addresses in drfed-server and in createFederation(), and asked Claude
Code to add the option wherever it is needed and to fix the tests the
upgrade broke. Claude Code traced each failure to the Fedify change
behind it, moved the option from createFederation() to the callers
that need it, and wrote the fixes above. mise run check and mise run
test pass.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/drfed/src/index.ts | 5 +-
packages/graphql/src/activity-log.test.ts | 34 ++-
packages/graphql/src/activity-log/keycache.ts | 14 +-
.../graphql/src/activity-log/outbound.test.ts | 1 +
.../graphql/src/activity-log/queue.test.ts | 12 +-
packages/graphql/src/federation.test.ts | 6 +-
pnpm-lock.yaml | 226 +++++++++---------
pnpm-workspace.yaml | 24 +-
8 files changed, 168 insertions(+), 154 deletions(-)
diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index 6f08cdc..8a7d90e 100644
--- a/packages/drfed/src/index.ts
+++ b/packages/drfed/src/index.ts
@@ -50,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, {
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
index bc0540f..c1f8a2c 100644
--- a/packages/graphql/src/activity-log.test.ts
+++ b/packages/graphql/src/activity-log.test.ts
@@ -103,9 +103,11 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
const a = await generateCryptoKeyPair();
const b = await generateCryptoKeyPair();
let currentKey = a.publicKey;
- let loads = 0;
+ // 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) => {
- loads += 1;
+ if (url === keyId.href) keyLoads += 1;
const key = new CryptographicKey({
id: keyId,
owner: actorIri,
@@ -183,10 +185,9 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
).toBase64()}`,
digestHeader,
);
- const firstLoads = loads;
- assert.equal(firstLoads, 1);
+ assert.equal(keyLoads, 1);
assert.equal((await send(payload("second"))).status, 202);
- assert.equal(loads, firstLoads);
+ assert.equal(keyLoads, 1);
assert.equal(await db.$count(schema.keyVersions), 1);
const [seenAgain] = await db.query.keyVersions.findMany();
assert.ok(seenAgain);
@@ -194,7 +195,7 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
Temporal.Instant.compare(seenAgain.lastSeen, firstVersion.lastSeen) > 0,
);
currentKey = b.publicKey;
- await kv.delete(["_fedify", "publicKey", keyId.href]);
+ 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(
@@ -240,7 +241,7 @@ it("records signed, rotated, tampered and rejected inbox deliveries with the ori
)?.actorId,
null,
);
- await kv.delete(["_fedify", "publicKey", keyId.href]);
+ await kv.delete(["_fedify", "publicKey", "2", keyId.href]);
assert.equal(await db.$count(schema.keyVersions), 2);
});
});
@@ -344,7 +345,7 @@ it("uses the Fedify KV serialization for RSA, Multikey and negative entries", as
});
await cache.set(keyId, key);
assert.deepEqual(
- await kv.get(["_fedify", "publicKey", keyId.href]),
+ await kv.get(["_fedify", "publicKey", "2", keyId.href]),
await key.toJsonLd(),
);
assert.ok((await cache.get(keyId)) instanceof CryptographicKey);
@@ -354,7 +355,10 @@ it("uses the Fedify KV serialization for RSA, Multikey and negative entries", as
controller: actorIri,
publicKey: ed.publicKey,
});
- await kv.set(["_fedify", "publicKey", keyId.href], await multi.toJsonLd());
+ 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);
@@ -363,7 +367,7 @@ it("uses the Fedify KV serialization for RSA, Multikey and negative entries", as
((await cache.getFetchError(keyId)) as { error: Error }).error.name,
"TypeError",
);
- await kv.set(["_fedify", "publicKey", keyId.href], "not a key");
+ await kv.set(["_fedify", "publicKey", "2", keyId.href], "not a key");
assert.equal(await cache.get(keyId), undefined);
});
@@ -574,7 +578,10 @@ async function testKey(): Promise {
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() });
+ 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 };
@@ -664,7 +671,10 @@ it("settles synchronous delivery and retains HTTP failure diagnostics", async ()
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() });
+ const federation = await createFederation(db, {
+ kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
+ });
const context = federation.createContext(new URL(inbox), undefined);
const key = await testKey();
const bad = {
diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts
index 86411b3..69ac2c1 100644
--- a/packages/graphql/src/activity-log/keycache.ts
+++ b/packages/graphql/src/activity-log/keycache.ts
@@ -32,6 +32,9 @@ type DiagnosticKeyCache = KeyCache & {
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(
@@ -58,20 +61,21 @@ export function createKeyCache(
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([...prefix, keyId.href]);
+ const value = await kv.get(entryKey(keyId));
if (value == null) return value;
const key = await parseKey(value, options);
- if (key == null) await kv.delete([...prefix, keyId.href]);
+ if (key == null) await kv.delete(entryKey(keyId));
return key;
},
async set(keyId, key) {
await kv.set(
- [...prefix, keyId.href],
+ entryKey(keyId),
key == null ? null : await key.toJsonLd(options),
- key == null ? { ttl: unavailableTtl } : undefined,
+ { ttl: key == null ? unavailableTtl : keyTtl },
);
},
async getFetchError(keyId) {
@@ -135,7 +139,7 @@ export async function trackedKey(
keyIri: string,
options: Loaders = {},
): Promise {
- const entry = JSON.stringify([keyIri]);
+ const entry = JSON.stringify([generation, keyIri]);
const brought = await Promise.all(
fetches
.filter(({ result }) => result === "hit" || result === "fetched")
diff --git a/packages/graphql/src/activity-log/outbound.test.ts b/packages/graphql/src/activity-log/outbound.test.ts
index 63ffe7b..481c842 100644
--- a/packages/graphql/src/activity-log/outbound.test.ts
+++ b/packages/graphql/src/activity-log/outbound.test.ts
@@ -90,6 +90,7 @@ async function createDeliveringContext(
): 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");
diff --git a/packages/graphql/src/activity-log/queue.test.ts b/packages/graphql/src/activity-log/queue.test.ts
index 9c01dad..ecb3a54 100644
--- a/packages/graphql/src/activity-log/queue.test.ts
+++ b/packages/graphql/src/activity-log/queue.test.ts
@@ -102,6 +102,7 @@ async function deliver(
): Promise {
const federation = await createFederation(db, {
kv: new MemoryKvStore(),
+ allowPrivateAddress: true,
...(queue == null ? {} : { queue, manuallyStartQueue: true }),
circuitBreaker: false,
outboxRetryPolicy: ({ attempts }) =>
@@ -196,10 +197,10 @@ it("keeps the status a redirected delivery ended with", async () => {
});
});
-it("follows a redirect to a location outside ASCII as either follower reads it", async () => {
+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 reads them back as Latin-1 and
- // `fetch()` as UTF-8, and each requests the path it read.
+ // 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) {
@@ -221,10 +222,7 @@ it("follows a redirect to a location outside ASCII as either follower reads it",
);
assert.equal(log?.status, "sent");
assert.equal(log?.statusCode, 202);
- assert.deepEqual(paths, [
- "/inbox",
- algorithm === "Ed25519" ? "/caf%C3%A9" : "/caf%C3%83%C2%A9",
- ]);
+ assert.deepEqual(paths, ["/inbox", "/caf%C3%83%C2%A9"]);
}
});
});
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/pnpm-lock.yaml b/pnpm-lock.yaml
index 2617ce7..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,13 +179,13 @@ 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)
@@ -301,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
@@ -329,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
@@ -843,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':
@@ -3921,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
@@ -3963,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
@@ -3996,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': {}
@@ -4009,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
@@ -4017,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
@@ -4259,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
@@ -4282,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
@@ -4309,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)
@@ -4331,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
@@ -4361,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
@@ -4382,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
@@ -5049,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
@@ -5076,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:
@@ -5151,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
@@ -5171,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
@@ -5186,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
@@ -5413,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
@@ -5549,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: {}
@@ -5682,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
@@ -5706,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
@@ -5723,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
@@ -5962,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
@@ -6184,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)
@@ -6199,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
@@ -6640,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:
@@ -6854,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
@@ -6910,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"
From 92dac7ae46efd92ff644fe421c4ce6c0baeb2080 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Thu, 1 Oct 2026 16:15:38 +0900
Subject: [PATCH 10/13] Speed up test runs with PGlite snapshots and a
dependency on build:server
Restore isolated GraphQL test databases from a migrated PGlite snapshot to avoid repeated initialization and migrations. Run tests after the server build without requiring the unused web build.
Codex was asked to apply snapshot reuse and compare test times. It implemented the harness change and measured mise run test at 59.77s before and 39.27s after. All 286 tests and mise run check passed.
The user modified the `test` task to depend only on `build:server` so that the frontend would not be built when the `test` task is run directly.
Assisted-by: Codex:gpt-6-astra
---
mise.toml | 2 +-
packages/graphql/src/harness.test.ts | 27 ++++++++++++++++++++++-----
2 files changed, 23 insertions(+), 6 deletions(-)
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/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);
From 206b2fd448eecc83dd38693cadde51e9898755c4 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Fri, 2 Oct 2026 19:53:52 +0900
Subject: [PATCH 11/13] Keep keys verified at FEP-ef61 compatible identifiers
Fedify 2.4 caches a key at a compatible identifier, such as
https://remote.example/.well-known/apgateway/did:key:.../actor#key,
only under ["__compatible", scope, keyIri], wrapped as
{ key, expires }, and never under ["2", keyIri]. trackedKey() read
only the latter, so an Object Integrity Proof verified with such a key
was logged as verified with no verification key nor key version.
trackedKey() now also reads the scoped entry of the purpose the
verification mechanism looked the key up for, chosen by the same rule
as Fedify's getCompatibleKeyScope(): multikey for Object Integrity
Proofs, httpSignature for HTTP signatures, cryptographicKey for Linked
Data Signatures. It unwraps the key only from a value Fedify's
isCompatibleKeyEntry() would accept, and takes a null key as none.
createKeyCache() gains compatibleKeyScope() with the same wire format,
so the serialization test covers scoped entries too. A regression
test sends such a proof twice, fetched and then cached, and checks
that both logs keep the same key version; it failed before this change
with a null verificationKeyId.
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152566804
The contributor asked Claude Code to apply the review following a plan
they had reviewed. Claude Code reproduced the reported symptom with
the regression test first, then wrote the fix and the tests. mise run
check and mise run test pass.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/graphql/src/activity-log.test.ts | 29 ++++-
.../graphql/src/activity-log/inbound.test.ts | 54 ++++++++
packages/graphql/src/activity-log/keycache.ts | 118 +++++++++++++++++-
.../graphql/src/activity-log/verification.ts | 9 +-
4 files changed, 204 insertions(+), 6 deletions(-)
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
index c1f8a2c..a6be373 100644
--- a/packages/graphql/src/activity-log.test.ts
+++ b/packages/graphql/src/activity-log.test.ts
@@ -35,6 +35,7 @@ import { recordInbound, recordOutbound } from "@drfed/models/activity-log";
import { observeKeyVersion } from "@drfed/models/key";
import {
type Context,
+ type KvKey,
MemoryKvStore,
SendActivityError,
type SenderKeyPair,
@@ -334,7 +335,7 @@ it("records missing signatures, failed key fetches and non-JSON bodies, and skip
});
});
-it("uses the Fedify KV serialization for RSA, Multikey and negative entries", async () => {
+it("uses the Fedify KV serialization for RSA, Multikey, scoped and negative entries", async () => {
const kv = new MemoryKvStore();
const cache = createKeyCache(kv);
const rsa = await generateCryptoKeyPair();
@@ -369,6 +370,32 @@ it("uses the Fedify KV serialization for RSA, Multikey and negative entries", as
);
await kv.set(["_fedify", "publicKey", "2", keyId.href], "not a key");
assert.equal(await cache.get(keyId), undefined);
+ // A key at a compatible identifier is wrapped with when its entry expires.
+ const scoped = cache.compatibleKeyScope("multikey");
+ const entry: KvKey = [
+ "_fedify",
+ "publicKey",
+ "__compatible",
+ "multikey",
+ keyId.href,
+ ];
+ const before = Temporal.Now.instant().epochMilliseconds;
+ await scoped.set(keyId, multi);
+ const stored = await kv.get<{ key: unknown; expires: number }>(entry);
+ assert.deepEqual(Object.keys(stored ?? {}).sort(), ["expires", "key"]);
+ assert.deepEqual(stored?.key, await multi.toJsonLd());
+ assert.ok((stored?.expires ?? 0) > before);
+ assert.ok((await scoped.get(keyId)) instanceof Multikey);
+ assert.equal(
+ await cache.compatibleKeyScope("httpSignature").get(keyId),
+ undefined,
+ );
+ await scoped.set(keyId, null);
+ assert.equal((await kv.get<{ key: unknown }>(entry))?.key, null);
+ assert.equal(await scoped.get(keyId), null);
+ await kv.set(entry, await multi.toJsonLd());
+ assert.equal(await scoped.get(keyId), undefined);
+ assert.equal(await kv.get(entry), undefined);
});
it("classifies accepted proofs independently of HTTP signature failure and describes malformed JSON-LD", async () => {
diff --git a/packages/graphql/src/activity-log/inbound.test.ts b/packages/graphql/src/activity-log/inbound.test.ts
index 4822b50..f71512e 100644
--- a/packages/graphql/src/activity-log/inbound.test.ts
+++ b/packages/graphql/src/activity-log/inbound.test.ts
@@ -245,6 +245,60 @@ it("records an Object Integrity Proof, and acknowledges a duplicate without rece
});
});
+it("records the key of an Object Integrity Proof at an FEP-ef61 compatible identifier", async () => {
+ await withTemporaryDatabase(async (db) => {
+ await seedLocalActor(db);
+ // Fedify caches a key at such an identifier only apart for each purpose.
+ const compatibleKeyId = new URL(
+ "https://remote.example/.well-known/apgateway/did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK/actor#key",
+ );
+ const pair = await generateCryptoKeyPair("Ed25519");
+ const { contextLoader, send } = await createRecorder(
+ db,
+ new Map([
+ [
+ compatibleKeyId.href,
+ new Multikey({
+ id: compatibleKeyId,
+ controller: actorIri,
+ publicKey: pair.publicKey,
+ }),
+ ],
+ ]),
+ );
+ const signed = await signObject(
+ new Create({
+ id: new URL("https://remote.example/activities/compatible"),
+ actor: actorIri,
+ object: new Note({
+ id: new URL("https://remote.example/notes/compatible"),
+ }),
+ }),
+ pair.privateKey,
+ compatibleKeyId,
+ { contextLoader },
+ );
+ const body = JSON.stringify(
+ await signed.toJsonLd({ format: "compact", contextLoader }),
+ );
+ // Fedify fetches the key the first time, and reads its cache the second.
+ assert.equal((await send(post(body))).status, 202);
+ assert.equal((await send(post(body))).status, 202);
+ const [first, second] = await logs(db);
+ assert.equal(first?.status, "received");
+ assert.equal(first?.verificationMechanism, "object_integrity_proof");
+ assert.equal(first?.verificationResult, "verified");
+ assert.notEqual(first?.verificationKeyId, null);
+ assert.equal(first?.verificationKey?.key.iri, compatibleKeyId.href);
+ assert.equal(
+ first?.verificationKey?.publicKey.x,
+ (await exportJwk(pair.publicKey)).x,
+ );
+ assert.equal(second?.verificationResult, "verified");
+ assert.equal(second?.verificationKeyId, first?.verificationKeyId);
+ });
+});
+
it("records the key Fedify verified with, whatever a later fetch returns", async () => {
await withTemporaryDatabase(async (db) => {
await seedLocalActor(db);
diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts
index 69ac2c1..f1dd13e 100644
--- a/packages/graphql/src/activity-log/keycache.ts
+++ b/packages/graphql/src/activity-log/keycache.ts
@@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import type { ActivityLogVerificationMechanism } from "@drfed/models/schema";
import type {
FetchKeyErrorResult,
KeyCache,
@@ -25,17 +26,62 @@ import { CryptographicKey, Multikey } from "@fedify/vocab";
import type { ObservedKeyFetch } from "./tracking.ts";
type Loaders = Parameters[1];
+type CompatibleKeyScope = "multikey" | "cryptographicKey" | "httpSignature";
+interface ScopedKeyCache {
+ get(keyId: URL): Promise;
+ set(
+ keyId: URL,
+ key: CryptographicKey | Multikey | null,
+ options?: { readonly expires?: Temporal.Instant },
+ ): Promise;
+ delete(keyId: URL): Promise;
+}
type DiagnosticKeyCache = KeyCache & {
getFetchError(keyId: URL): Promise;
setFetchError(
keyId: URL,
error: FetchKeyErrorResult | undefined,
): Promise;
+ compatibleKeyScope(scope: CompatibleKeyScope): ScopedKeyCache;
};
-// Fedify's KvKeyCache keeps each key below the prefix under this generation.
+// Fedify's KvKeyCache keeps each key below the prefix under this generation,
+// and a key at an FEP-ef61 compatible identifier only under this segment,
+// apart for each purpose it is looked up for.
const generation = "2";
+const compatible = "__compatible";
const keyTtl = Temporal.Duration.from({ days: 30 });
const unavailableTtl = Temporal.Duration.from({ minutes: 10 });
+// Fedify caches a key at a compatible identifier for no longer than an hour.
+const compatibleKeyTtl = Temporal.Duration.from({ hours: 1 });
+
+/**
+ * The purpose Fedify looks a key at a compatible identifier up for, by the
+ * mechanism it verifies with, as its `getCompatibleKeyScope()` tells it:
+ * Fedify fetches the key of an Object Integrity Proof as a `Multikey`, that
+ * of an HTTP signature with portable key resolvers, and that of a Linked Data
+ * Signature without.
+ */
+const compatibleKeyScopes = {
+ http_signature: "httpSignature",
+ ld_signature: "cryptographicKey",
+ object_integrity_proof: "multikey",
+} as const satisfies Record<
+ ActivityLogVerificationMechanism,
+ CompatibleKeyScope
+>;
+
+/**
+ * Mirror of Fedify's `isCompatibleKeyEntry()`, which is not exported.
+ * @returns Whether the value is an entry of a key at a compatible identifier.
+ */
+const isCompatibleKeyEntry = (
+ value: unknown,
+): value is { readonly key: unknown; readonly expires: number } =>
+ value != null &&
+ typeof value === "object" &&
+ "key" in value &&
+ "expires" in value &&
+ typeof value.expires === "number";
async function parseKey(
value: unknown,
@@ -122,6 +168,60 @@ export function createKeyCache(
};
await kv.set(errorKey(keyId), value, { ttl: unavailableTtl });
},
+ // Fedify keeps the owner a portable actor's document vouched for too,
+ // which only a gateway key has, and this does not.
+ compatibleKeyScope(scope) {
+ const scopedEntry = (id: URL): KvKey => [
+ ...prefix,
+ compatible,
+ scope,
+ id.href,
+ ];
+ return {
+ async get(keyId) {
+ const entry = await kv.get(scopedEntry(keyId));
+ if (entry === undefined) return undefined;
+ if (
+ !isCompatibleKeyEntry(entry) ||
+ entry.expires <= Temporal.Now.instant().epochMilliseconds
+ ) {
+ await kv.delete(scopedEntry(keyId));
+ return undefined;
+ }
+ if (entry.key === null) return null;
+ const key = await parseKey(entry.key, options);
+ if (key == null) await kv.delete(scopedEntry(keyId));
+ return key;
+ },
+ async set(keyId, key, { expires } = {}) {
+ const now = Temporal.Now.instant();
+ const latest = now.add(
+ key == null ? unavailableTtl : compatibleKeyTtl,
+ );
+ const until =
+ expires == null || Temporal.Instant.compare(latest, expires) < 0
+ ? latest
+ : expires;
+ const ttl = now.until(until);
+ if (ttl.sign <= 0) {
+ await kv.delete(scopedEntry(keyId));
+ return undefined;
+ }
+ await kv.set(
+ scopedEntry(keyId),
+ {
+ key: key == null ? null : await key.toJsonLd(options),
+ expires: until.epochMilliseconds,
+ },
+ { ttl },
+ );
+ return key == null ? undefined : until;
+ },
+ async delete(keyId) {
+ await kv.delete(scopedEntry(keyId));
+ },
+ };
+ },
};
}
@@ -131,19 +231,33 @@ export function createKeyCache(
* 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.
+ * A key at an FEP-ef61 compatible identifier is in the entry of the purpose
+ * the mechanism looked it up for, whose value wraps the key.
* @param fetches The key fetches of the one verification, in order.
+ * @param mechanism The mechanism of the verification.
* @returns The key, or null when no fetch brought one.
*/
export async function trackedKey(
fetches: readonly ObservedKeyFetch[],
keyIri: string,
+ mechanism: ActivityLogVerificationMechanism | null,
options: Loaders = {},
): Promise {
const entry = JSON.stringify([generation, keyIri]);
+ const scoped =
+ mechanism == null
+ ? null
+ : JSON.stringify([compatible, compatibleKeyScopes[mechanism], keyIri]);
+ const held = ({ keys }: ObservedKeyFetch): readonly unknown[] => [
+ ...(keys.get(entry) ?? []),
+ ...(scoped == null ? [] : (keys.get(scoped) ?? [])).map((value) =>
+ isCompatibleKeyEntry(value) ? value.key : undefined,
+ ),
+ ];
const brought = await Promise.all(
fetches
.filter(({ result }) => result === "hit" || result === "fetched")
- .flatMap(({ keys }) => keys.get(entry) ?? [])
+ .flatMap(held)
.map((value) => (value == null ? undefined : parseKey(value, options))),
);
return brought.findLast((key) => key != null) ?? null;
diff --git a/packages/graphql/src/activity-log/verification.ts b/packages/graphql/src/activity-log/verification.ts
index 4494c73..ffcb2dd 100644
--- a/packages/graphql/src/activity-log/verification.ts
+++ b/packages/graphql/src/activity-log/verification.ts
@@ -358,9 +358,12 @@ export async function observeVerification(
const key =
verdict.keyIri == null
? null
- : await trackedKey(verdict.keyFetches ?? [], verdict.keyIri, {
- contextLoader,
- });
+ : await trackedKey(
+ verdict.keyFetches ?? [],
+ verdict.keyIri,
+ verdict.mechanism,
+ { contextLoader },
+ );
const version =
key?.publicKey == null || verdict.keyIri == null
? null
From 648e19fe4f8374948ba1bac2f7d716de49d1b032 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Fri, 2 Oct 2026 19:59:27 +0900
Subject: [PATCH 12/13] Keep every addressing path and check log actors
activity_log_actors kept a single via_collection_iri per log and actor,
so an actor reached through two collections kept whichever the database
returned last, and one also addressed directly lost the collections.
It now records addressed_directly, and a row in the new
activity_log_actor_collections table for each collection, referencing
its actor row with ON DELETE CASCADE. inboundActorRows() merges the
entries of an actor into one row with sorted, distinct collection IRIs,
so the result no longer depends on their order.
Each actor row also copies the created of its log, indexed as
(actor_id, created desc, log_id desc), so that an actor's logs can be
paged over the rows themselves in the order the instance's logs use.
actors.activityLogLinks relates an actor to them.
recordInbound() and recordOutbound() now refuse to relate a log to any
actor, as inbox owner, sender or addressed actor, that is not a local
actor of the log's instance, and record nothing then. Neither foreign
key can tell, and a composite foreign key would conflict with
activity_logs.actor_id being set to null when the actor is deleted.
The migration has not been released, so it is generated again instead
of being followed by another.
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152711390
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152711915
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152711534
The contributor asked Claude Code to apply the reviews following a
plan they had reviewed. Claude Code wrote the schema, recording and
test changes and regenerated the migration. mise run build, mise run
check and mise run test pass.
Assisted-by: Claude Code:claude-opus-5-5
---
.../graphql/src/activity-log/inbound.test.ts | 36 ++--
.../graphql/src/activity-log/outbound.test.ts | 25 +--
packages/models/README.md | 10 +-
.../migration.sql | 16 +-
.../snapshot.json | 103 ++++++++++-
packages/models/src/activity-log.test.ts | 168 ++++++++++++++----
packages/models/src/activity-log.ts | 86 +++++++--
packages/models/src/relations.ts | 21 +++
packages/models/src/schema.ts | 44 ++++-
9 files changed, 429 insertions(+), 80 deletions(-)
rename packages/models/drizzle/{20260929224246_add_activity_logs_and_keys => 20261002105651_add_activity_logs_and_keys}/migration.sql (87%)
rename packages/models/drizzle/{20260929224246_add_activity_logs_and_keys => 20261002105651_add_activity_logs_and_keys}/snapshot.json (96%)
diff --git a/packages/graphql/src/activity-log/inbound.test.ts b/packages/graphql/src/activity-log/inbound.test.ts
index f71512e..bb0497b 100644
--- a/packages/graphql/src/activity-log/inbound.test.ts
+++ b/packages/graphql/src/activity-log/inbound.test.ts
@@ -141,7 +141,12 @@ async function createRecorder(
const logs = (db: Database) =>
db.query.activityLogs.findMany({
orderBy: { id: "asc" },
- with: { verificationKey: { with: { key: true } }, actorLinks: true },
+ with: {
+ verificationKey: { with: { key: true } },
+ actorLinks: {
+ with: { collections: { orderBy: { collectionIri: "asc" } } },
+ },
+ },
});
it("records the key of the Linked Data Signature that verified, not of the HTTP signature", async () => {
@@ -1600,15 +1605,21 @@ it("relates a log to every local actor it concerns, once", async () => {
const { send } = await createRecorder(db);
const followers = "https://remote.example.com/users/bob/followers";
const following = "https://remote.example.com/users/bob/following";
+ const featured = "https://remote.example.com/users/bob/featured";
await addActorCollectionItem(db, remoteActorId, "followers", localActorId);
+ await addActorCollectionItem(db, remoteActorId, "following", 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 }],
+ [
+ "both",
+ sharedInbox,
+ { to: [localActorIri], cc: [following, followers] },
+ ],
+ ["empty", sharedInbox, { cc: featured }],
["public", sharedInbox, { to: "as:Public" }],
[
"typed",
@@ -1628,16 +1639,21 @@ it("relates a log to every local actor it concerns, once", async () => {
link.actorId,
link.inboxOwner,
link.addressed,
- link.viaCollectionIri,
+ link.addressedDirectly,
+ link.collections.map(({ collectionIri }) => collectionIri),
]),
]),
[
- ["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]]],
+ ["own", localActorId, [[localActorId, true, true, true, []]]],
+ ["shared", null, [[localActorId, false, true, true, []]]],
+ ["object", null, [[localActorId, false, true, true, []]]],
+ ["array", null, [[localActorId, false, true, true, []]]],
+ ["members", null, [[localActorId, false, true, false, [followers]]]],
+ [
+ "both",
+ null,
+ [[localActorId, false, true, true, [followers, following]]],
+ ],
["empty", null, []],
["public", null, []],
["typed", null, []],
diff --git a/packages/graphql/src/activity-log/outbound.test.ts b/packages/graphql/src/activity-log/outbound.test.ts
index 481c842..b8afb7c 100644
--- a/packages/graphql/src/activity-log/outbound.test.ts
+++ b/packages/graphql/src/activity-log/outbound.test.ts
@@ -223,16 +223,21 @@ it("logs every recipient of a shared inbox and strips blind recipients before de
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,
- },
- ]);
+ const links = await db.query.activityLogActors.findMany();
+ assert.deepEqual(
+ links.map(({ created: _, ...link }) => link),
+ [
+ {
+ logId: log?.id,
+ actorId: localActorId,
+ inboxOwner: false,
+ addressed: false,
+ addressedDirectly: false,
+ sender: true,
+ },
+ ],
+ );
+ assert.equal(links[0]?.created.toString(), log?.created.toString());
});
assert.deepEqual(
[
diff --git a/packages/models/README.md b/packages/models/README.md
index fe5f8c8..5f54a18 100644
--- a/packages/models/README.md
+++ b/packages/models/README.md
@@ -53,8 +53,14 @@ 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.
+per log and actor. An addressed actor keeps every way it was addressed:
+`addressed_directly` when the activity named the actor itself, and a row in
+`activity_log_actor_collections` for each addressed collection it was a member
+of. Each row copies the log's `created`, by which an actor's logs are ordered.
+Both record functions insert these rows in the same transaction as the log.
+They record nothing and throw when the inbox owner, the sender, or an
+addressed actor is not a local actor of the log's instance, since neither
+foreign key can tell.
`@drfed/models/key` exposes public-JWK validation, RFC 7638/RFC 8037 SHA-256
thumbprints, and `observeKeyVersion`. `keys` identifies each exact key IRI;
diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql b/packages/models/drizzle/20261002105651_add_activity_logs_and_keys/migration.sql
similarity index 87%
rename from packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql
rename to packages/models/drizzle/20261002105651_add_activity_logs_and_keys/migration.sql
index 1e21679..3cb0a65 100644
--- a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql
+++ b/packages/models/drizzle/20261002105651_add_activity_logs_and_keys/migration.sql
@@ -2,15 +2,24 @@ CREATE TYPE "activity_log_direction" AS ENUM('inbound', 'outbound');--> statemen
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_actor_collections" (
+ "log_id" uuid,
+ "actor_id" uuid,
+ "collection_iri" text,
+ CONSTRAINT "activity_log_actor_collections_pkey" PRIMARY KEY("log_id","actor_id","collection_iri")
+);
+--> 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,
+ "addressed_directly" boolean DEFAULT false NOT NULL,
"sender" boolean DEFAULT false NOT NULL,
- "via_collection_iri" text,
+ "created" timestamp with time zone NOT NULL,
CONSTRAINT "activity_log_actors_pkey" PRIMARY KEY("log_id","actor_id"),
- CONSTRAINT "activity_log_actors_role_check" CHECK ("inbox_owner" OR "addressed" OR "sender")
+ CONSTRAINT "activity_log_actors_role_check" CHECK ("inbox_owner" OR "addressed" OR "sender"),
+ CONSTRAINT "activity_log_actors_addressed_directly_check" CHECK (NOT "addressed_directly" OR "addressed")
);
--> statement-breakpoint
CREATE TABLE "activity_log_attempts" (
@@ -80,13 +89,14 @@ CREATE TABLE "keys" (
"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_actor_created_index" ON "activity_log_actors" ("actor_id","created" desc,"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_actor_collections" ADD CONSTRAINT "activity_log_actor_collections_link_fkey" FOREIGN KEY ("log_id","actor_id") REFERENCES "activity_log_actors"("log_id","actor_id") ON DELETE CASCADE;--> 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
diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json b/packages/models/drizzle/20261002105651_add_activity_logs_and_keys/snapshot.json
similarity index 96%
rename from packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
rename to packages/models/drizzle/20261002105651_add_activity_logs_and_keys/snapshot.json
index 009c741..cf5a36f 100644
--- a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json
+++ b/packages/models/drizzle/20261002105651_add_activity_logs_and_keys/snapshot.json
@@ -1,7 +1,7 @@
{
"version": "8",
"dialect": "postgres",
- "id": "10a7ac3c-ffbe-46a9-8ea9-4f50ee544b12",
+ "id": "1e2806c8-1ca7-4e28-9bc7-0dce59e8d752",
"prevIds": [
"478b925d-8ac8-466c-9804-bb055fdd6489",
"7c4a0afb-efbc-4ae7-b6b5-ebc6daaddb3e"
@@ -102,6 +102,12 @@
"entityType": "tables",
"schema": "public"
},
+ {
+ "isRlsEnabled": false,
+ "name": "activity_log_actor_collections",
+ "entityType": "tables",
+ "schema": "public"
+ },
{
"isRlsEnabled": false,
"name": "activity_log_actors",
@@ -379,6 +385,45 @@
"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_actor_collections"
+ },
+ {
+ "type": "uuid",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "actor_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actor_collections"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "collection_iri",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actor_collections"
+ },
{
"type": "uuid",
"typeSchema": null,
@@ -431,6 +476,19 @@
"schema": "public",
"table": "activity_log_actors"
},
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "addressed_directly",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "activity_log_actors"
+ },
{
"type": "boolean",
"typeSchema": null,
@@ -445,14 +503,14 @@
"table": "activity_log_actors"
},
{
- "type": "text",
+ "type": "timestamp with time zone",
"typeSchema": null,
- "notNull": false,
+ "notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
- "name": "via_collection_iri",
+ "name": "created",
"entityType": "columns",
"schema": "public",
"table": "activity_log_actors"
@@ -2305,6 +2363,13 @@
"nullsFirst": false,
"opclass": null
},
+ {
+ "value": "\"created\" desc",
+ "isExpression": true,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
{
"value": "\"log_id\" desc",
"isExpression": true,
@@ -2318,7 +2383,7 @@
"with": "",
"method": "btree",
"concurrently": false,
- "name": "activity_log_actor_log_index",
+ "name": "activity_log_actor_created_index",
"entityType": "indexes",
"schema": "public",
"table": "activity_log_actors"
@@ -2712,6 +2777,19 @@
"schema": "public",
"table": "activities"
},
+ {
+ "nameExplicit": true,
+ "columns": ["log_id", "actor_id"],
+ "schemaTo": "public",
+ "tableTo": "activity_log_actors",
+ "columnsTo": ["log_id", "actor_id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "name": "activity_log_actor_collections_link_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "activity_log_actor_collections"
+ },
{
"nameExplicit": false,
"columns": ["log_id"],
@@ -3050,6 +3128,14 @@
"schema": "public",
"table": "sessions"
},
+ {
+ "columns": ["log_id", "actor_id", "collection_iri"],
+ "nameExplicit": false,
+ "name": "activity_log_actor_collections_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "activity_log_actor_collections"
+ },
{
"columns": ["log_id", "actor_id"],
"nameExplicit": false,
@@ -3328,6 +3414,13 @@
"schema": "public",
"table": "activity_log_actors"
},
+ {
+ "value": "NOT \"addressed_directly\" OR \"addressed\"",
+ "name": "activity_log_actors_addressed_directly_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",
diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts
index 56f88b2..3a8508c 100644
--- a/packages/models/src/activity-log.test.ts
+++ b/packages/models/src/activity-log.test.ts
@@ -336,58 +336,102 @@ it("keeps one row per log and actor and requires a role", async () => {
await migrate({ credentials: { driver: "pglite", client } });
const db = drizzle({ client, schema, relations });
const instanceId = uuidV7();
+ const otherInstanceId = 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"],
+ const strangerId = uuidV7();
+ const remoteId = uuidV7();
+ await db.insert(schema.instances).values([
+ { id: instanceId, host: "local.example" },
+ { id: otherInstanceId, host: "other.example" },
+ ]);
+ for (const [id, username, instance, local] of [
+ [actorId, "alice", instanceId, true],
+ [otherId, "bob", instanceId, true],
+ [strangerId, "carol", otherInstanceId, true],
+ [remoteId, "dave", instanceId, false],
] as const) {
const iri = `https://local.example/users/${id}`;
+ if (local) await db.insert(schema.localActors).values({ id });
await db.insert(schema.resources).values({ id, iri, kind: "actor" });
await db.insert(schema.actors).values({
id,
+ localId: local ? id : null,
type: "Person",
username,
- instanceId,
+ instanceId: instance,
inboxUrl: `${iri}/inbox`,
});
}
- const collection = "https://local.example/collections/1";
- assert.deepEqual(
- inboundActorRows({
- actorId,
- addressed: [
- { actorId, viaCollectionIri: collection },
- { actorId },
- { actorId: otherId, viaCollectionIri: collection },
+ const [first, second] = [
+ "https://local.example/collections/1",
+ "https://local.example/collections/2",
+ ];
+ // Every way an actor was addressed is kept, whatever order it comes in.
+ for (const collections of [
+ [first, second],
+ [second, first],
+ ]) {
+ assert.deepEqual(
+ inboundActorRows({
+ actorId,
+ addressed: [
+ ...collections.map((viaCollectionIri) => ({
+ actorId,
+ viaCollectionIri,
+ })),
+ { actorId },
+ { actorId, viaCollectionIri: first },
+ { actorId: otherId, viaCollectionIri: first },
+ ],
+ }),
+ [
+ {
+ actorId,
+ inboxOwner: true,
+ addressed: true,
+ addressedDirectly: true,
+ collectionIris: [first, second],
+ },
+ {
+ actorId: otherId,
+ addressed: true,
+ addressedDirectly: false,
+ collectionIris: [first],
+ },
],
- }),
- [
- { actorId, inboxOwner: true, addressed: true, viaCollectionIri: null },
- { actorId: otherId, addressed: true, viaCollectionIri: collection },
- ],
- );
- const log = await recordInbound(db, {
+ );
+ }
+ const created = Temporal.Instant.from("2026-09-01T00:00:00.123456Z");
+ const inbound = {
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(),
+ created,
+ completed: created,
+ } as const;
+ const log = await recordInbound(db, {
+ ...inbound,
+ actorId,
+ addressed: [
+ { actorId, viaCollectionIri: second },
+ { actorId },
+ { actorId, viaCollectionIri: first },
+ { actorId: otherId, viaCollectionIri: first },
+ ],
});
- const sent = await recordOutbound(db, {
+ const outbound = {
instanceId,
- actorId,
inboxUrl: "https://remote.example/inbox",
activityIri: "https://local.example/activity/1",
payload: {},
+ } as const;
+ const sent = await recordOutbound(db, {
+ ...outbound,
+ actorId,
recipientIris: ["https://remote.example/a", "https://remote.example/b"],
});
assert.deepEqual(sent.recipientIris, [
@@ -396,6 +440,7 @@ it("keeps one row per log and actor and requires a role", async () => {
]);
const links = await db.query.activityLogActors.findMany({
orderBy: { logId: "asc", actorId: "asc" },
+ with: { collections: { orderBy: { collectionIri: "asc" } } },
});
assert.deepEqual(
links.map((link) => [
@@ -403,32 +448,87 @@ it("keeps one row per log and actor and requires a role", async () => {
link.actorId,
link.inboxOwner,
link.addressed,
+ link.addressedDirectly,
link.sender,
+ link.collections.map(({ collectionIri }) => collectionIri),
]),
[
- [log.id, actorId, true, true, false],
- [log.id, otherId, false, true, false],
- [sent.id, actorId, false, false, true],
+ [log.id, actorId, true, true, true, false, [first, second]],
+ [log.id, otherId, false, true, false, false, [first]],
+ [sent.id, actorId, false, false, false, true, []],
],
);
+ // Each row copies the created of its log, which orders an actor's logs.
+ assert.deepEqual(
+ links.map((link) => link.created.toString()),
+ [log, log, sent].map((row) => row.created.toString()),
+ );
await assert.rejects(
db
.insert(schema.activityLogActors)
- .values({ logId: log.id, actorId, sender: true }),
+ .values({ logId: log.id, actorId, sender: true, created }),
);
await assert.rejects(
db
.insert(schema.activityLogActors)
- .values({ logId: sent.id, actorId: otherId }),
+ .values({ logId: sent.id, actorId: otherId, created }),
+ );
+ await assert.rejects(
+ db.insert(schema.activityLogActors).values({
+ logId: sent.id,
+ actorId: otherId,
+ inboxOwner: true,
+ addressedDirectly: true,
+ created,
+ }),
);
+ await assert.rejects(
+ db.insert(schema.activityLogActorCollections).values({
+ logId: sent.id,
+ actorId: otherId,
+ collectionIri: first,
+ }),
+ );
+ // Only local actors of the log's instance, in any role.
+ const logs = await db.$count(schema.activityLogs);
+ for (const refused of [
+ () => recordOutbound(db, { ...outbound, actorId: strangerId }),
+ () => recordOutbound(db, { ...outbound, actorId: remoteId }),
+ () => recordInbound(db, { ...inbound, actorId: remoteId }),
+ () =>
+ recordInbound(db, {
+ ...inbound,
+ actorId,
+ addressed: [{ actorId: strangerId }],
+ }),
+ () =>
+ recordInbound(db, {
+ ...inbound,
+ addressed: [{ actorId: remoteId, viaCollectionIri: first }],
+ }),
+ ]) {
+ await assert.rejects(refused, /local actors of its instance/u);
+ }
+ assert.equal(await db.$count(schema.activityLogs), logs);
const found = await db.query.actors.findFirst({
where: { id: actorId },
- with: { activityLogs: { orderBy: { created: "desc", id: "desc" } } },
+ with: {
+ activityLogs: { orderBy: { created: "desc", id: "desc" } },
+ activityLogLinks: { orderBy: { created: "desc", logId: "desc" } },
+ },
});
assert.deepEqual(
found?.activityLogs.map((row) => row.id),
[sent.id, log.id],
);
+ assert.deepEqual(
+ found?.activityLogLinks.map((link) => link.logId),
+ [sent.id, log.id],
+ );
+ await db
+ .delete(schema.activityLogs)
+ .where(eq(schema.activityLogs.id, log.id));
+ assert.equal(await db.$count(schema.activityLogActorCollections), 0);
} finally {
await client.close();
}
diff --git a/packages/models/src/activity-log.ts b/packages/models/src/activity-log.ts
index 72d317a..76419a5 100644
--- a/packages/models/src/activity-log.ts
+++ b/packages/models/src/activity-log.ts
@@ -14,16 +14,18 @@
// 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 { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
-import type { Database } from "./db.ts";
+import type { Database, Transaction } from "./db.ts";
import {
type ActivityLog,
type NewActivityLog,
type NewActivityLogActor,
+ activityLogActorCollections,
activityLogActors,
activityLogAttempts,
activityLogs,
+ actors,
} from "./schema.ts";
import { type Uuid, uuidV7 } from "./uuid.ts";
@@ -68,10 +70,14 @@ export type OutboundLogEntry = Omit<
readonly activityIri: string;
};
-type ActorRow = Omit;
+type ActorRow = Omit & {
+ /** The addressed collections that reached the actor, sorted. */
+ readonly collectionIris?: readonly string[];
+};
/**
- * Merge the inbox owner and addressed actors into one row per actor.
+ * Merge the inbox owner and addressed actors into one row per actor, keeping
+ * every way each was addressed, whatever order they come in.
* @returns The actor rows of an inbound log.
*/
export function inboundActorRows(entry: {
@@ -84,14 +90,17 @@ export function inboundActorRows(entry: {
}
for (const { actorId, viaCollectionIri } of entry.addressed ?? []) {
const row = rows.get(actorId);
+ const collectionIris = row?.collectionIris ?? [];
rows.set(actorId, {
...row,
actorId,
addressed: true,
- viaCollectionIri:
- row?.addressed === true && row.viaCollectionIri == null
- ? null
- : (viaCollectionIri ?? null),
+ addressedDirectly:
+ row?.addressedDirectly === true || viaCollectionIri == null,
+ collectionIris:
+ viaCollectionIri == null
+ ? collectionIris
+ : [...new Set([...collectionIris, viaCollectionIri])].toSorted(),
});
}
return [...rows.values()];
@@ -134,12 +143,43 @@ const jsonb = (payload: unknown) =>
const text = (value: string | null | undefined): string | null =>
value?.replaceAll("\0", "\ufffd") ?? null;
+/**
+ * Refuse to relate a log to anything but local actors of its instance, which
+ * neither foreign key can tell.
+ * @throws {Error} When an actor is remote, or of another instance.
+ */
+async function checkLocalActors(
+ tx: Transaction,
+ instanceId: Uuid,
+ actorIds: readonly (Uuid | null | undefined)[],
+): Promise {
+ const ids = [...new Set(actorIds.filter((id) => id != null))];
+ if (ids.length === 0) return;
+ const local = await tx.$count(
+ actors,
+ and(
+ inArray(actors.id, ids),
+ eq(actors.instanceId, instanceId),
+ isNotNull(actors.localId),
+ ),
+ );
+ if (local !== ids.length) {
+ throw new Error(
+ "An activity log relates only to local actors of its instance.",
+ );
+ }
+}
+
async function insertLog(
db: Database,
log: NewActivityLog,
- actors: readonly ActorRow[],
+ links: readonly ActorRow[],
): Promise {
return await db.transaction(async (tx) => {
+ await checkLocalActors(tx, log.instanceId, [
+ log.actorId,
+ ...links.map(({ actorId }) => actorId),
+ ]);
const [row] = await tx
.insert(activityLogs)
.values({
@@ -149,10 +189,24 @@ async function insertLog(
})
.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 })));
+ if (links.length > 0) {
+ await tx.insert(activityLogActors).values(
+ links.map(({ collectionIris: _, ...link }) => ({
+ ...link,
+ logId: row.id,
+ created: row.created,
+ })),
+ );
+ }
+ const collections = links.flatMap(({ actorId, collectionIris = [] }) =>
+ collectionIris.map((collectionIri) => ({
+ logId: row.id,
+ actorId,
+ collectionIri,
+ })),
+ );
+ if (collections.length > 0) {
+ await tx.insert(activityLogActorCollections).values(collections);
}
return row;
});
@@ -160,7 +214,10 @@ async function insertLog(
/**
* Persist one inbound observation with its actor rows in one transaction.
+ * The inbox owner and every addressed actor must be local actors of
+ * `instanceId`; otherwise nothing is recorded.
* @returns The inserted inbound log.
+ * @throws {Error} When an actor is not a local actor of the instance.
*/
export async function recordInbound(
db: Database,
@@ -201,7 +258,10 @@ export async function receiveInbound(db: Database, id: Uuid): Promise {
/**
* Start a delivery observation. The payload is the document before signing.
+ * The sender must be a local actor of `instanceId`; otherwise nothing is
+ * recorded.
* @returns The inserted queued outbound log.
+ * @throws {Error} When the sender is not a local actor of the instance.
*/
export async function recordOutbound(
db: Database,
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index 04a586b..bb21e3d 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -75,6 +75,23 @@ export const relations = defineRelations(schema, (r) => ({
to: r.actors.id,
optional: false,
}),
+ collections: r.many.activityLogActorCollections({
+ from: [r.activityLogActors.logId, r.activityLogActors.actorId],
+ to: [
+ r.activityLogActorCollections.logId,
+ r.activityLogActorCollections.actorId,
+ ],
+ }),
+ },
+ activityLogActorCollections: {
+ actorLink: r.one.activityLogActors({
+ from: [
+ r.activityLogActorCollections.logId,
+ r.activityLogActorCollections.actorId,
+ ],
+ to: [r.activityLogActors.logId, r.activityLogActors.actorId],
+ optional: false,
+ }),
},
accounts: {
instances: r.many.instances({
@@ -285,6 +302,10 @@ export const relations = defineRelations(schema, (r) => ({
from: r.actors.id.through(r.activityLogActors.actorId),
to: r.activityLogs.id.through(r.activityLogActors.logId),
}),
+ activityLogLinks: r.many.activityLogActors({
+ from: r.actors.id,
+ to: r.activityLogActors.actorId,
+ }),
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 f7897f0..e2d8b5b 100644
--- a/packages/models/src/schema.ts
+++ b/packages/models/src/schema.ts
@@ -22,6 +22,7 @@ import {
char,
check,
customType,
+ foreignKey,
index,
integer,
json,
@@ -738,7 +739,10 @@ export const activityLogAttempts = pgTable(
],
);
-/** The local actors a log concerns, one row per log and actor. */
+/**
+ * The local actors a log concerns, one row per log and actor; each must be a
+ * local actor of the log's instance, which the record functions check.
+ */
export const activityLogActors = pgTable(
"activity_log_actors",
{
@@ -751,9 +755,13 @@ export const activityLogActors = pgTable(
.notNull()
.references(() => actors.id, { onDelete: "cascade" }),
inboxOwner: boolean("inbox_owner").notNull().default(false),
+ /** Addressed directly or through a collection. */
addressed: boolean().notNull().default(false),
+ /** Addressed by its own IRI; collections are in a row each. */
+ addressedDirectly: boolean("addressed_directly").notNull().default(false),
sender: boolean().notNull().default(false),
- viaCollectionIri: text("via_collection_iri"),
+ /** The log's `created`, which an actor's logs are ordered by. */
+ created: instant().notNull(),
},
(table) => [
primaryKey({ columns: [table.logId, table.actorId] }),
@@ -761,7 +769,35 @@ export const activityLogActors = pgTable(
"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)),
+ check(
+ "activity_log_actors_addressed_directly_check",
+ sql`NOT ${table.addressedDirectly} OR ${table.addressed}`,
+ ),
+ index("activity_log_actor_created_index").on(
+ table.actorId,
+ desc(table.created),
+ desc(table.logId),
+ ),
+ ],
+);
+
+/** Each addressed collection a log reached a local actor through. */
+export const activityLogActorCollections = pgTable(
+ "activity_log_actor_collections",
+ {
+ logId: uuid("log_id").$type().notNull(),
+ actorId: uuid("actor_id").$type().notNull(),
+ collectionIri: text("collection_iri").notNull(),
+ },
+ (table) => [
+ primaryKey({
+ columns: [table.logId, table.actorId, table.collectionIri],
+ }),
+ foreignKey({
+ name: "activity_log_actor_collections_link_fkey",
+ columns: [table.logId, table.actorId],
+ foreignColumns: [activityLogActors.logId, activityLogActors.actorId],
+ }).onDelete("cascade"),
],
);
export type Key = typeof keys.$inferSelect;
@@ -771,6 +807,8 @@ 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 ActivityLogActorCollection =
+ typeof activityLogActorCollections.$inferSelect;
export type ActivityLogDirection =
(typeof activityLogDirectionEnum.enumValues)[number];
export type ActivityLogStatus =
From ce1f0e4437ea597910f50c82ca1aed34c31aea75 Mon Sep 17 00:00:00 2001
From: ChanHaeng Lee <2chanhaeng@gmail.com>
Date: Fri, 2 Oct 2026 20:04:16 +0900
Subject: [PATCH 13/13] Page log attempts and describe actor log edges
ActivityLog.attempts was a list of every attempt a delivery ever made,
which a retry policy without a limit can make arbitrarily long, and
turning it into a connection later would break existing queries. It is
now a Relay connection like Key.versions, oldest first, paged over the
(log_id, created, id) index.
Actor.activityLogs is now paged over the actor's activity_log_actors
rows, the way Account.instances is over its memberships, ordered by the
created they copy from their logs and their log IDs, as before. Its
edges tell how each delivery concerns the actor: inboxOwner, sender,
addressed, addressedDirectly, and viaCollections, every addressed
collection the actor was a member of when the delivery arrived. A
shared-inbox delivery, whose actor is null, thus still explains why it
is in the actor's feed. The filters apply to the logs through the
relation, and the through relation actors.activityLogs is gone.
Cursors of Actor.activityLogs change from (created, id) to
(created, logId); the API has not been released and the web app does
not use it yet.
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152711680
https://github.com/fedify-dev/drfed/pull/101#discussion_r4152711534
The contributor asked Claude Code to apply the reviews following a
plan they had reviewed. Claude Code wrote the connections and the
tests, and found that Pothos cannot add a composite primary key to a
narrowed selection, so the collections of each edge select every
column. mise run build, mise run check and mise run test pass.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/graphql/README.md | 8 +-
packages/graphql/src/activity-log.test.ts | 201 +++++++++++++++++-
packages/graphql/src/activity-log/entry.ts | 120 ++++++++---
.../graphql/src/activity-log/inbound.test.ts | 16 +-
packages/models/src/activity-log.test.ts | 5 -
packages/models/src/relations.ts | 4 -
6 files changed, 309 insertions(+), 45 deletions(-)
diff --git a/packages/graphql/README.md b/packages/graphql/README.md
index 357af9e..0ee8b7c 100644
--- a/packages/graphql/README.md
+++ b/packages/graphql/README.md
@@ -53,7 +53,13 @@ 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.
+far as DrFed has stored the collection's members. Each of its edges tells how
+the delivery concerns the actor: `inboxOwner`, `sender`, `addressed`,
+`addressedDirectly` when the activity named the actor itself, and
+`viaCollections`, every addressed collection the actor was a member of when the
+delivery arrived, so that a shared-inbox delivery, whose `actor` is null, still
+explains why it is in the actor's feed. `ActivityLog.attempts` is a
+connection, oldest attempt first.
Wrap the federation HTTP surface with `createInboundRecorder`, passing a
federation made by `createFederation` and the root origin. Every inbox `POST`
diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts
index a6be373..f862209 100644
--- a/packages/graphql/src/activity-log.test.ts
+++ b/packages/graphql/src/activity-log.test.ts
@@ -31,7 +31,11 @@ import createFederation, {
type TrackedFederation,
} from "@drfed/graphql/federation";
import { schema } from "@drfed/models";
-import { recordInbound, recordOutbound } from "@drfed/models/activity-log";
+import {
+ recordInbound,
+ recordOutbound,
+ settleOutbound,
+} from "@drfed/models/activity-log";
import { observeKeyVersion } from "@drfed/models/key";
import {
type Context,
@@ -592,6 +596,201 @@ it("denies anonymous/nonmember access including node typename and remote connect
});
});
+it("pages through the attempts of a delivery, oldest first", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const outgoing = {
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ activityIri: "https://local.example/activity",
+ payload: {},
+ } as const;
+ const outbound = await recordOutbound(db, outgoing);
+ for (const [status, statusCode, error] of [
+ ["failed", 503, "First"],
+ ["failed", 502, "Second"],
+ ["sent", 202, null],
+ ] as const) {
+ await settleOutbound(db, {
+ ...outgoing,
+ status,
+ statusCode,
+ error,
+ attempted: true,
+ });
+ }
+ const inbound = await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ status: "received",
+ payload: {},
+ });
+ const page = async (id: string, first: number, after?: string) => {
+ const result = await (
+ await post(
+ {
+ query: `query($id: ID!, $first: Int!, $after: String) {
+ node(id: $id) { ... on ActivityLog {
+ attempts(first: $first, after: $after) {
+ edges { node { succeeded statusCode error } }
+ pageInfo { hasNextPage endCursor }
+ }
+ } }
+ }`,
+ variables: {
+ id: Buffer.from(`ActivityLog:${id}`).toString("base64"),
+ first,
+ after,
+ },
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ return result.data.node.attempts;
+ };
+ const attempts = [];
+ let after: string | undefined;
+ for (const hasNextPage of [true, true, false]) {
+ const { edges, pageInfo } = await page(outbound.id, 1, after);
+ assert.equal(pageInfo.hasNextPage, hasNextPage);
+ attempts.push(...edges.map((edge: { node: unknown }) => edge.node));
+ after = pageInfo.endCursor;
+ }
+ assert.deepEqual(attempts, [
+ { succeeded: false, statusCode: 503, error: "First" },
+ { succeeded: false, statusCode: 502, error: "Second" },
+ { succeeded: true, statusCode: 202, error: null },
+ ]);
+ assert.deepEqual(await page(inbound.id, 10), {
+ edges: [],
+ pageInfo: { hasNextPage: false, endCursor: null },
+ });
+ });
+});
+
+it("tells on each edge of an actor's logs how the delivery concerns it", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ const auth = await seedAuthenticatedLocalInstance(db);
+ await seedLocalActor(db);
+ const followers = "https://remote.example/users/alice/followers";
+ const following = "https://remote.example/users/alice/following";
+ const at = (minute: number) =>
+ Temporal.Instant.from(`2026-09-01T00:0${minute}:00Z`);
+ // Inserted in another order than they were created in.
+ const owned = await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: inbox,
+ status: "received",
+ payload: {},
+ addressed: [{ actorId: localActorId }],
+ created: at(3),
+ });
+ const shared = await recordInbound(db, {
+ ...observed,
+ instanceId: localInstanceId,
+ inboxUrl: "https://test-instance.drfed.org/inbox",
+ status: "received",
+ payload: {},
+ addressed: [
+ { actorId: localActorId, viaCollectionIri: following },
+ { actorId: localActorId, viaCollectionIri: followers },
+ ],
+ created: at(1),
+ });
+ const sent = await recordOutbound(db, {
+ instanceId: localInstanceId,
+ actorId: localActorId,
+ inboxUrl: "https://remote.example/inbox",
+ activityIri: "https://local.example/activity",
+ payload: {},
+ created: at(2),
+ });
+ const page = async (
+ first: number,
+ after?: string,
+ filter?: Record,
+ ) => {
+ const result = await (
+ await post(
+ {
+ query: `query($id: ID!, $first: Int!, $after: String, $filter: ActivityLogFilter) {
+ node(id: $id) { ... on Actor {
+ activityLogs(first: $first, after: $after, filter: $filter) {
+ edges {
+ inboxOwner sender addressed addressedDirectly viaCollections
+ node { uuid actor { uuid } }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ } }
+ }`,
+ variables: {
+ id: globalId("Actor", localActorId),
+ first,
+ after,
+ filter,
+ },
+ },
+ auth,
+ )
+ ).json();
+ assert.equal(result.errors, undefined, JSON.stringify(result.errors));
+ return result.data.node.activityLogs;
+ };
+ const first = await page(2);
+ const second = await page(2, first.pageInfo.endCursor);
+ assert.deepEqual(
+ [first.pageInfo.hasNextPage, second.pageInfo.hasNextPage],
+ [true, false],
+ );
+ assert.deepEqual(
+ [...first.edges, ...second.edges],
+ [
+ {
+ inboxOwner: true,
+ sender: false,
+ addressed: true,
+ addressedDirectly: true,
+ viaCollections: [],
+ node: { uuid: owned.id, actor: { uuid: localActorId } },
+ },
+ {
+ inboxOwner: false,
+ sender: true,
+ addressed: false,
+ addressedDirectly: false,
+ viaCollections: [],
+ node: { uuid: sent.id, actor: { uuid: localActorId } },
+ },
+ {
+ inboxOwner: false,
+ sender: false,
+ addressed: true,
+ addressedDirectly: false,
+ viaCollections: [followers, following],
+ node: { uuid: shared.id, actor: null },
+ },
+ ],
+ );
+ const uuids = async (filter: Record) =>
+ (await page(10, undefined, filter)).edges.map(
+ (edge: { node: { uuid: string } }) => edge.node.uuid,
+ );
+ assert.deepEqual(await uuids({ direction: "outbound" }), [sent.id]);
+ assert.deepEqual(await uuids({ direction: "inbound" }), [
+ owned.id,
+ shared.id,
+ ]);
+ });
+});
+
/**
* A key of the test's own to deliver with, since local key pairs arrive with
* #87.
diff --git a/packages/graphql/src/activity-log/entry.ts b/packages/graphql/src/activity-log/entry.ts
index 54a354c..11c7954 100644
--- a/packages/graphql/src/activity-log/entry.ts
+++ b/packages/graphql/src/activity-log/entry.ts
@@ -305,7 +305,7 @@ const ActivityLogRef = builder.drizzleNode("activityLogs", {
"inbound request whose handling threw is the exception. Null for " +
"accepted ones.",
}),
- attempts: t.relation("attempts", {
+ attempts: t.relatedConnection("attempts", {
query: { orderBy: { created: "asc", id: "asc" } },
description:
"Outbound: each attempt that ended, oldest first, including the " +
@@ -348,20 +348,39 @@ const ActivityLogRef = builder.drizzleNode("activityLogs", {
});
export const ActivityLog: DrFedObjectRef = ActivityLogRef;
+type LogFilter = typeof ActivityLogFilter.$inferInput | null | undefined;
+const logWhere = (filter: LogFilter) => ({
+ ...(filter?.direction == null ? {} : { direction: filter.direction }),
+ ...(filter?.status == null ? {} : { status: filter.status }),
+ ...(filter?.type == null ? {} : { type: filter.type }),
+});
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 }),
- },
+ query: ({ filter }: { filter?: LogFilter }) => ({
+ where: logWhere(filter),
orderBy: { created: "desc", id: "desc" },
}),
});
+// Paged over an actor's links, which copy the created of their logs, so that
+// each edge tells how the log concerns the actor.
+const actorLogsConnection = drizzleConnectionHelpers(
+ builder,
+ "activityLogActors",
+ {
+ query: ({ filter }: { filter?: LogFilter }) => ({
+ where: { log: logWhere(filter) },
+ orderBy: { created: "desc", logId: "desc" },
+ }),
+ select: (nestedSelection) => ({
+ with: {
+ log: nestedSelection(),
+ // Every column, since Pothos cannot add a composite primary key to
+ // a narrower selection.
+ collections: { orderBy: { collectionIri: "asc" } },
+ },
+ }),
+ resolveNode: (link) => link.log,
+ },
+);
builder.drizzleObjectField("instances", "activityLogs", (t) =>
t.connection({
type: ActivityLog,
@@ -382,26 +401,61 @@ builder.drizzleObjectField("instances", "activityLogs", (t) =>
}),
);
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),
- }),
+ 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. Each edge tells how the delivery concerns the " +
+ "actor. Restricted to instance members and administrators.",
+ select: (args, ctx, nestedSelection) =>
+ ({
+ columns: { localId: true },
+ with: {
+ instance: { columns: { localId: true } },
+ activityLogLinks: actorLogsConnection.getQuery(
+ args,
+ ctx,
+ nestedSelection,
+ ),
+ },
+ }) as const,
+ resolve: (actor, args, ctx) =>
+ actorLogsConnection.resolve(actor.activityLogLinks, args, ctx, actor),
+ authScopes: (actor) =>
+ actor.localId == null ? false : access(actor.instance.localId),
+ },
+ {},
+ {
+ fields: (edge) => ({
+ inboxOwner: edge.exposeBoolean("inboxOwner", {
+ description: "Whether the delivery arrived at this actor's inbox.",
+ }),
+ sender: edge.exposeBoolean("sender", {
+ description: "Whether this actor sent the delivery.",
+ }),
+ addressed: edge.exposeBoolean("addressed", {
+ description:
+ "Whether the activity addressed this actor, directly or " +
+ "through a collection.",
+ }),
+ addressedDirectly: edge.exposeBoolean("addressedDirectly", {
+ description: "Whether the activity addressed this actor by its IRI.",
+ }),
+ viaCollections: edge.field({
+ type: ["URL"],
+ description:
+ "The addressed collections this actor was a member of when the " +
+ "delivery arrived, as far as DrFed had stored them, in IRI " +
+ "order. Empty when no collection addressed it.",
+ resolve: (link) =>
+ link.collections.map(({ collectionIri }) => collectionIri),
+ }),
+ }),
+ },
+ ),
);
diff --git a/packages/graphql/src/activity-log/inbound.test.ts b/packages/graphql/src/activity-log/inbound.test.ts
index bb0497b..2bc8b31 100644
--- a/packages/graphql/src/activity-log/inbound.test.ts
+++ b/packages/graphql/src/activity-log/inbound.test.ts
@@ -1669,7 +1669,7 @@ it("relates a log to every local actor it concerns, once", async () => {
await query(
{
query: `{
- actor: node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 20) { edges { node { activityIri } } } } }
+ actor: node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 20) { edges { addressedDirectly viaCollections node { activityIri } } } } }
instance: node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 20) { edges { node { activityIri actor { uuid } } } } } }
}`,
},
@@ -1692,6 +1692,20 @@ it("relates a log to every local actor it concerns, once", async () => {
"shared",
"own",
]);
+ assert.deepEqual(
+ before.actor.activityLogs.edges
+ .slice(0, 2)
+ .map(
+ (edge: { addressedDirectly: boolean; viaCollections: string[] }) => [
+ edge.addressedDirectly,
+ edge.viaCollections,
+ ],
+ ),
+ [
+ [true, [followers, following]],
+ [false, [followers]],
+ ],
+ );
assert.equal(before.instance.activityLogs.edges.length, deliveries.length);
assert.equal(
before.instance.activityLogs.edges.at(-1).node.actor.uuid,
diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts
index 3a8508c..dac90d1 100644
--- a/packages/models/src/activity-log.test.ts
+++ b/packages/models/src/activity-log.test.ts
@@ -513,14 +513,9 @@ it("keeps one row per log and actor and requires a role", async () => {
const found = await db.query.actors.findFirst({
where: { id: actorId },
with: {
- activityLogs: { orderBy: { created: "desc", id: "desc" } },
activityLogLinks: { orderBy: { created: "desc", logId: "desc" } },
},
});
- assert.deepEqual(
- found?.activityLogs.map((row) => row.id),
- [sent.id, log.id],
- );
assert.deepEqual(
found?.activityLogLinks.map((link) => link.logId),
[sent.id, log.id],
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index bb21e3d..86d648d 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -298,10 +298,6 @@ 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),
- }),
activityLogLinks: r.many.activityLogActors({
from: r.actors.id,
to: r.activityLogActors.actorId,