diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ee30f7..06dcffc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -362,6 +362,20 @@ When adding a new object or field, follow the existing `builder.drizzleNode()` and `t.drizzleField()` patterns. Keep resolver database access through `ctx.db`. +Activity delivery observations live in `activity_logs`, independently of the +ActivityPub `activities` resources. The federation HTTP surface must pass +through `createInboundRecorder` with a federation made by `createFederation`, +which tracks the public keys, spans and measurements Fedify reports for each +request, and the deployment's root origin. Keep the public-key cache +serialization compatible with the installed Fedify version, and read only the +spans, events and metrics Fedify documents in its OpenTelemetry manual; never +verify a request again. Inbox listeners +must call `markHandled()`, which is how a log tells a received activity from an +acknowledged one. Use `deliverActivity` for outgoing delivery, and create the +federation through `createFederation`, which observes the outbox queue so that +each attempt Fedify's worker makes settles its log. Log contents are private +to local instance members and administrators, including Relay node lookups. + CLI and server changes ---------------------- diff --git a/mise.toml b/mise.toml index ce2f680..4a67d40 100644 --- a/mise.toml +++ b/mise.toml @@ -98,7 +98,7 @@ node scripts/add-license/main.mts [tasks.test] description = "Run tests" -depends = ["build"] +depends = ["build:server"] usage = ''' flag "-d --debug" help="Run tests in debug mode" ''' diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 6d6a572..8a7d90e 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -18,7 +18,9 @@ import { writeFile } from "node:fs/promises"; import process from "node:process"; import { createYogaServer } from "@drfed/graphql"; -import createFederation from "@drfed/graphql/federation"; +import createFederation, { + createInboundRecorder, +} from "@drfed/graphql/federation"; import { schema } from "@drfed/graphql/schema"; import { migrate } from "@drfed/models"; import { PgliteKvStore } from "@fedify/pglite"; @@ -48,7 +50,10 @@ async function runServer(options: ServerOptions) { "driver" in credentials ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); - const federation = await createFederation(options.drizzle.db, { kv }); + const federation = await createFederation(options.drizzle.db, { + kv, + allowPrivateAddress: true, + }); const { emailFrom, mailer, rootOrigin, loginOrigins } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { @@ -60,7 +65,11 @@ async function runServer(options: ServerOptions) { await warnAboutStrandedInstances(options.drizzle.db, rootOrigin); const server = serve({ fetch: createFetchHandler({ - federation, + federation: createInboundRecorder({ + db: options.drizzle.db, + federation, + rootOrigin, + }), rootOrigin, serveControlSurface: yogaServer.fetch, }), diff --git a/packages/drfed/src/serving.test.ts b/packages/drfed/src/serving.test.ts index fea5552..696e159 100644 --- a/packages/drfed/src/serving.test.ts +++ b/packages/drfed/src/serving.test.ts @@ -21,9 +21,13 @@ import { findStrandedInstances, warnAboutStrandedInstances, } from "@drfed/drfed/serving"; +import createFederation, { + createInboundRecorder, +} from "@drfed/graphql/federation"; import { migrate, relations, schema } from "@drfed/models"; import { uuidV7 as uuid } from "@drfed/models/uuid"; import { PGlite } from "@electric-sql/pglite"; +import { MemoryKvStore } from "@fedify/fedify"; import { describe, it } from "@logtape/testing-node/autoload"; import { drizzle } from "drizzle-orm/pglite"; @@ -241,3 +245,54 @@ describe("findStrandedInstances()", () => { } }); }); + +it("records inbox requests only on the instance surface", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, relations, schema }); + const localId = uuid(); + await db.insert(schema.localInstances).values({ + id: localId, + slug: "logs", + expires: Temporal.Now.instant().add({ hours: 24 }), + }); + await db + .insert(schema.instances) + .values({ id: localId, localId, host: "logs.drfed.net" }); + const kv = new MemoryKvStore(); + const federation = await createFederation(db, { kv }); + const fetch = createFetchHandler({ + rootOrigin, + federation: createInboundRecorder({ db, federation, rootOrigin }), + serveControlSurface: () => new Response("control"), + }); + const body = JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + actor: "https://remote.example/alice", + }); + assert.equal( + ( + await fetch( + new Request("https://logs.drfed.net/inbox", { method: "POST", body }), + ) + ).status, + 401, + ); + assert.equal( + ( + await fetch( + new Request("https://drfed.net/inbox", { method: "POST", body }), + ) + ).status, + 200, + ); + const rows = await db.query.activityLogs.findMany(); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.instanceId, localId); + assert.equal(rows[0]?.status, "unverified"); + } finally { + await client.close(); + } +}); diff --git a/packages/drfed/src/serving.ts b/packages/drfed/src/serving.ts index 05ff70d..5f7c13c 100644 --- a/packages/drfed/src/serving.ts +++ b/packages/drfed/src/serving.ts @@ -19,8 +19,8 @@ import type { Database } from "@drfed/models"; import { getLogger } from "@logtape/logtape"; /** - * The part of a Fedify `Federation` that the router needs, narrowed so that - * the routing can be exercised without building one. + * The federation surface (including the inbound recorder) that the router + * needs, narrowed so that the routing can be exercised without building one. */ export interface FederationHandler { fetch( @@ -43,7 +43,7 @@ export interface FetchHandlerOptions { readonly rootOrigin: URL; /** - * The ActivityPub surface, served on instance subdomains. + * The ActivityPub surface with inbox recording, served on instance subdomains. */ readonly federation: FederationHandler; diff --git a/packages/graphql/README.md b/packages/graphql/README.md index e6b42f4..357af9e 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -24,15 +24,16 @@ Usage ~~~~ ts import { createYogaServer } from "@drfed/graphql"; -import createFederation from "@drfed/graphql/federation"; +import createFederation, { createInboundRecorder } from "@drfed/graphql/federation"; const federation = await createFederation(db, { kv }); +const recordedInbox = createInboundRecorder({ db, federation, rootOrigin }); const yoga = createYogaServer(db, federation, { loginOrigins: new Set(["https://drfed.example.com"]), }); serve({ fetch: (request) => - federation.fetch(request, { onNotFound: yoga.fetch, contextData: undefined }), + recordedInbox.fetch(request, { onNotFound: yoga.fetch, contextData: undefined }), }); ~~~~ @@ -41,3 +42,106 @@ registered. `createYogaServer` accepts a Drizzle database instance, that federation, and server options, and returns a GraphQL Yoga server ready to handle HTTP requests. The federation is stored in the resolver context as is; `createYogaServer` never registers anything on it. + + +Activity logs +------------- + +`Instance.activityLogs` and `Actor.activityLogs` expose delivery observations, +newest first, with direction, status, and type filters. Only accepted local +instance members and site administrators can read them, including through +Relay node IDs. `Actor.activityLogs` lists the deliveries that arrived at the +actor's inbox, that the actor sent, and that are addressed to the actor through +any inbox, the shared one included. Addressing through a collection counts as +far as DrFed has stored the collection's members. + +Wrap the federation HTTP surface with `createInboundRecorder`, passing a +federation made by `createFederation` and the root origin. Every inbox `POST` +is logged, whether or not its body is JSON; logging errors never replace +federation responses. A request Fedify throws on is logged too, with the +exception in `error` and no `statusCode`, and the exception is thrown again. +An inbound log keeps: + + - The request as received: `rawBody` (or `rawBodyBase64` when the body is not + valid UTF-8), `requestHeaders`, and `requestUrl`. `payload` is the body + parsed as JSON for querying, and is `null` when it does not parse or + holds U+0000 or an unpaired surrogate, which PostgreSQL cannot store. + `cookie` headers are dropped, and `authorization` is kept only for the + `Signature` scheme. + - `inboxUrl`, the canonical IRI of the inbox, however the request spelled its + host or scheme. + - `verificationMechanism` and `verificationResult`, what Fedify reported of + verifying the request, as its OpenTelemetry manual documents: the + `activitypub.signature.verification.duration` result of each mechanism it + tried, the `activitypub.signature.key_fetch.duration` and + `activitypub.key.lookup` results of the keys it fetched for each, the + `*.verify` spans naming their keys, and the + `activitypub.activity.received` event. DrFed verifies nothing again. The + result is `unattempted` when Fedify answered before verifying, such as for + a body that is not JSON or an inbox whose owner does not exist, even if + the body carries a signature or proof. Proofs are found as JSON-LD, + however `proof` is spelled. + - A refusal is `key_fetch_error` rather than `invalid_signature` when the + last key fetch of the mechanism brought no usable key, whichever + mechanism it is: the signature was then not checked, or only against a + cached key that did not verify. `error` tells why as `keyFetchError:` + followed by the status the server of the key answered with, by `cached` + for the record of an earlier failure, or by the lookup result Fedify + counted, such as `network_error` or `invalid` for a document that holds no + key. When Fedify itself names the fetch of an HTTP signature's key as + failed, what follows is the status or the type of the error it reports. + - The result tells whether a signature or proof verified, not whether it + authenticated the activity. Object Integrity Proofs that verify without + their keys' controllers covering the activity's actor are `verified`, and + the refusal is `status` `rejected`, with `error` telling that Fedify did + not accept them, even when the HTTP signature Fedify went on to failed. + Fedify reports a Linked Data Signature that verifies without its key's + owner being the actor the same as one that does not verify, so that one + is `invalid_signature`. + - `verificationKey`, the public key version that mechanism used, even when + verification failed: the last key its cache entry held during a key fetch + of that one verification which brought a key, whether read or fetched, + even when fetching it again then failed. A key another mechanism of the + same request found under the same IRI is not it, nor is one read from the + cache that the verification could not use. `signedKeyIri` is the `keyId` + the HTTP signature declares. The two may name different keys. + - `status`, which follows the response Fedify gave: `received` when the inbox + listener ran, `acknowledged` when the request was answered 2xx without it + (a duplicate, for instance), `rejected` when a verified activity was + refused or its handling threw, and `unverified` otherwise. With an inbox + queue, a log is + `acknowledged` when the request is answered and becomes `received` once the + queue worker runs the listener; the queued message carries its log ID. + +`KeyVersion.firstSeen` and `lastSeen` are DrFed observation times, not remote +rotation times or evidence of continuous use. `Key` and `KeyVersion` hold +public material only and are readable by any authenticated viewer. + +`deliverActivity` is the outbound entry point for explicit recipients once +local actor keys are available (#87). It removes `bto` and `bcc` before +delivery, records one row per destination inbox with every recipient sharing +it in `recipientIris`, and settles each delivery independently. A recipient +without an ID or an inbox is left out, because Fedify does not deliver to it. +The logged `payload` is the document before signing. `attempts` keeps every +attempt that ended, with the status the remote inbox answered, read from the +responses `fetch()` publishes on `diagnostics_channel`, and the causes of +network errors. When the inbox redirects a delivery, the status is the one +the redirects ended with, whether Fedify followed them, as it does when it +signs the request, or `fetch()` did. With a message queue, `createFederation` +observes Fedify's outbox worker, and each queued message carries the log it +belongs to. A delivery becomes `sent` when Fedify reports +`activitypub.activity.sent` for its inbox, stays `failed` while it is retried, +and ends `permanently_failed`, or `abandoned` when Fedify measures it abandoned +after its retries ran out. A delivery Fedify returns from without sending to +the inbox, or without enqueuing a message to it, is `permanently_failed` with +no attempt, since Fedify makes none. The queue is handed to Fedify as one +without native retries, so that every retry follows Fedify's policy and is +logged. Activity resource persistence (#88), +retention policies, and the activity-log UI (#13) are separate. + +URL fields hold only values that parse as URLs, and no text field holds +U+0000; anything else a remote server sent stays in `rawBody` and +`requestHeaders`, and in `payload` when PostgreSQL can store it. `error` and +`responseBody` keep what a remote server answered with U+FFFD for each U+0000. +Logs are ordered by `created`, which for an inbound log is when the request +arrived; `completed` is when DrFed answered it. diff --git a/packages/graphql/package.json b/packages/graphql/package.json index d1b6fcd..097f42c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -81,6 +81,10 @@ "./origin": { "types": "./dist/origin.d.mts", "default": "./dist/origin.mjs" + }, + "./activity-log": { + "types": "./dist/activity-log/entry.d.mts", + "default": "./dist/activity-log/entry.mjs" } }, "files": [ @@ -98,7 +102,8 @@ "src/instance.ts", "src/schema.ts", "src/object.ts", - "src/origin.ts" + "src/origin.ts", + "src/activity-log/entry.ts" ], "dts": { "sourcemap": true, @@ -125,6 +130,7 @@ "@fedify/vocab": "catalog:", "@logtape/graphql-yoga": "catalog:", "@logtape/logtape": "catalog:", + "@opentelemetry/api": "^1.9.1", "@pothos/core": "^4.13.0", "@pothos/plugin-drizzle": "^0.17.4", "@pothos/plugin-errors": "^4.9.1", diff --git a/packages/graphql/src/activity-log.test.ts b/packages/graphql/src/activity-log.test.ts new file mode 100644 index 0000000..c1f8a2c --- /dev/null +++ b/packages/graphql/src/activity-log.test.ts @@ -0,0 +1,804 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Each request observes the preceding request's cache and database changes. + +// oxlint-disable no-await-in-loop max-statements id-length +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { + classifyInbound, + createInboundRecorder, + createKeyCache, + deliverActivity, + describeActivity, +} from "@drfed/graphql/activity-log"; +import createFederation, { + type TrackedFederation, +} from "@drfed/graphql/federation"; +import { schema } from "@drfed/models"; +import { recordInbound, recordOutbound } from "@drfed/models/activity-log"; +import { observeKeyVersion } from "@drfed/models/key"; +import { + type Context, + MemoryKvStore, + SendActivityError, + type SenderKeyPair, + generateCryptoKeyPair, + signRequest, +} from "@fedify/fedify"; +import { + type Activity, + Create, + CryptographicKey, + type DocumentLoader, + Multikey, + Person, + type Recipient, +} from "@fedify/vocab"; +import { eq, isNull } from "drizzle-orm"; + +import { withInbox } from "./activity-log/remote.test.ts"; +import { withTemporaryDatabase, withTestHarness } from "./harness.test.ts"; +import { + accountId, + globalId, + localActorId, + localInstanceId, + remoteActorId, + remoteInstanceId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "./seed.test.ts"; + +const actorIri = new URL("https://remote.example/users/alice"); +const keyId = new URL(`${actorIri.href}#main-key`); +const inbox = `https://test-instance.drfed.org/users/${localActorId}/inbox`; +const fetchOptions = { contextData: undefined }; +const rootOrigin = new URL("https://drfed.org"); +const observed = { + verificationResult: "no_signature", + body: new TextEncoder().encode("{}"), + created: Temporal.Now.instant(), + completed: Temporal.Now.instant(), +} as const; +const payload = (id: string) => ({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://remote.example/activities/${id}`, + type: "Create", + actor: actorIri.href, + object: { + id: `https://remote.example/notes/${id}`, + type: "Note", + content: "test", + }, + bcc: ["https://private.example/recipient"], +}); +const request = (body: unknown, url = inbox) => + new Request(url, { + method: "POST", + headers: { "content-type": "application/activity+json" }, + body: JSON.stringify(body), + }); + +it("records signed, rotated, tampered and rejected inbox deliveries with the original JSON", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const kv = new MemoryKvStore(); + const a = await generateCryptoKeyPair(); + const b = await generateCryptoKeyPair(); + let currentKey = a.publicKey; + // Fedify fetches the actor on every request to check that it owns the key, + // so only fetches of the key itself tell whether the cache served it. + let keyLoads = 0; + const documentLoader: DocumentLoader = async (url) => { + if (url === keyId.href) keyLoads += 1; + const key = new CryptographicKey({ + id: keyId, + owner: actorIri, + publicKey: currentKey, + }); + const document = + url === actorIri.href + ? await new Person({ id: actorIri, publicKey: key }).toJsonLd() + : await key.toJsonLd(); + return { documentUrl: url, contextUrl: null, document }; + }; + const { contextLoader } = ( + await createFederation(db, { kv }) + ).createContext(new URL(inbox), undefined); + const federation = await createFederation(db, { + kv, + contextLoaderFactory: () => contextLoader, + documentLoaderFactory: () => documentLoader, + }); + const recorder = createInboundRecorder({ db, federation, rootOrigin }); + const send = async ( + body: unknown, + privateKey = a.privateKey, + url = inbox, + ) => { + const signed = await signRequest(request(body, url), privateKey, keyId); + return await recorder.fetch(signed, fetchOptions); + }; + assert.equal((await send(payload("first"))).status, 202); + const [first] = await db.query.activityLogs.findMany(); + assert.ok(first); + assert.equal(first.status, "received"); + assert.equal(first.verificationMechanism, "http_signature"); + assert.equal(first.verificationResult, "verified"); + assert.equal(first.error, null); + assert.equal(first.requestUrl, inbox); + assert.deepEqual( + JSON.parse(new TextDecoder().decode(first.body!)), + payload("first"), + ); + assert.equal(first.type, "Create"); + assert.equal(first.objectType, "Note"); + assert.equal(first.signedKeyIri, keyId.href); + assert.ok(first.verificationKeyId); + assert.equal(first.actorId, localActorId); + assert.deepEqual(first.payload, payload("first")); + const [firstVersion] = await db.query.keyVersions.findMany(); + assert.ok(firstVersion); + // Digest failure happens before a key lookup. An existing cache entry is + // not evidence that this request used that key. + const badDigest = await signRequest( + request(payload("bad-digest")), + a.privateKey, + keyId, + ); + badDigest.headers.set( + "digest", + "SHA-256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ); + assert.equal((await recorder.fetch(badDigest, fetchOptions)).status, 401); + const digestLog = await db.query.activityLogs.findFirst({ + where: { activityIri: payload("bad-digest").id }, + }); + assert.equal(digestLog?.signedKeyIri, keyId.href); + assert.equal(digestLog?.verificationKeyId, null); + assert.equal(digestLog?.verificationResult, "invalid_signature"); + const digestHeader = new Map(digestLog?.headers).get("digest"); + assert.equal( + digestHeader, + "SHA-256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ); + assert.notEqual( + `SHA-256=${new Uint8Array( + await crypto.subtle.digest("SHA-256", new Uint8Array(digestLog!.body!)), + ).toBase64()}`, + digestHeader, + ); + assert.equal(keyLoads, 1); + assert.equal((await send(payload("second"))).status, 202); + assert.equal(keyLoads, 1); + assert.equal(await db.$count(schema.keyVersions), 1); + const [seenAgain] = await db.query.keyVersions.findMany(); + assert.ok(seenAgain); + assert.ok( + Temporal.Instant.compare(seenAgain.lastSeen, firstVersion.lastSeen) > 0, + ); + currentKey = b.publicKey; + await kv.delete(["_fedify", "publicKey", "2", keyId.href]); + assert.equal((await send(payload("rotated"), b.privateKey)).status, 202); + assert.equal(await db.$count(schema.keyVersions), 2); + assert.equal( + (await db.query.activityLogs.findFirst({ where: { id: first.id } })) + ?.verificationKeyId, + firstVersion.id, + ); + // Sign with A while the advertised key is B: cryptographic verification fails. + assert.equal((await send(payload("tampered"))).status, 401); + const bad = await db.query.activityLogs.findFirst({ + where: { activityIri: payload("tampered").id }, + }); + assert.equal(bad?.status, "unverified"); + assert.equal(bad?.verificationResult, "invalid_signature"); + assert.ok(bad?.verificationKeyId); + const mismatch = { + ...payload("mismatch"), + actor: "https://other.example/actor", + }; + assert.equal((await send(mismatch, b.privateKey)).status, 401); + const rejected = await db.query.activityLogs.findFirst({ + where: { activityIri: mismatch.id }, + }); + assert.equal(rejected?.status, "rejected"); + assert.equal(rejected?.verificationResult, "verified"); + assert.equal(rejected?.error, rejected?.responseBody); + assert.match(rejected?.error ?? "", /do not match/u); + assert.equal( + ( + await send( + payload("shared"), + b.privateKey, + "https://test-instance.drfed.org/inbox", + ) + ).status, + 202, + ); + assert.equal( + ( + await db.query.activityLogs.findFirst({ + where: { activityIri: payload("shared").id }, + }) + )?.actorId, + null, + ); + await kv.delete(["_fedify", "publicKey", "2", keyId.href]); + assert.equal(await db.$count(schema.keyVersions), 2); + }); +}); + +it("records missing signatures, failed key fetches and non-JSON bodies, and skips non-inbox requests", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const kv = new MemoryKvStore(); + const { contextLoader } = ( + await createFederation(db, { kv }) + ).createContext(new URL(inbox), undefined); + const federation = await createFederation(db, { + kv, + contextLoaderFactory: () => contextLoader, + documentLoaderFactory: () => () => + Promise.reject(new TypeError("offline")), + }); + const recorder = createInboundRecorder({ db, federation, rootOrigin }); + assert.equal( + (await recorder.fetch(request(payload("unsigned")), fetchOptions)).status, + 401, + ); + const [unsigned] = await db.query.activityLogs.findMany(); + assert.ok(unsigned); + assert.equal(unsigned.status, "unverified"); + assert.equal(unsigned.signedKeyIri, null); + assert.equal(unsigned.verificationKeyId, null); + assert.equal(unsigned.verificationMechanism, "http_signature"); + assert.equal(unsigned.verificationResult, "no_signature"); + const pair = await generateCryptoKeyPair(); + assert.equal( + ( + await recorder.fetch( + await signRequest( + request(payload("unavailable")), + pair.privateKey, + keyId, + ), + fetchOptions, + ) + ).status, + 401, + ); + const failed = await db.query.activityLogs.findFirst({ + where: { activityIri: payload("unavailable").id }, + }); + assert.equal(failed?.signedKeyIri, keyId.href); + assert.equal(failed?.verificationKeyId, null); + assert.equal(failed?.verificationResult, "key_fetch_error"); + assert.match(failed?.error ?? "", /keyFetchError/u); + const invalid = await recorder.fetch( + new Request(inbox, { method: "POST", body: "not JSON" }), + fetchOptions, + ); + assert.equal(invalid.status, 400); + const unparsed = await db.query.activityLogs.findFirst({ + where: { statusCode: 400 }, + }); + assert.equal( + new TextDecoder().decode(unparsed?.body ?? undefined), + "not JSON", + ); + assert.equal(unparsed?.type, null); + assert.deepEqual(unparsed?.types, []); + // Fedify refused the body before verifying anything. + assert.equal(unparsed?.verificationMechanism, null); + assert.equal(unparsed?.verificationResult, "unattempted"); + assert.equal( + await db.$count(schema.activityLogs, isNull(schema.activityLogs.payload)), + 1, + ); + await recorder.fetch(new Request(inbox), fetchOptions); + await recorder.fetch( + request({}, "https://test-instance.drfed.org/not-an-inbox"), + fetchOptions, + ); + assert.equal(await db.$count(schema.activityLogs), 3); + await recorder.fetch(request(null), fetchOptions); + assert.equal(await db.$count(schema.activityLogs), 4); + assert.equal( + await db.$count(schema.activityLogs, isNull(schema.activityLogs.payload)), + 1, + ); + await db.execute(`DROP TABLE activity_logs CASCADE`); + assert.equal( + (await recorder.fetch(request(payload("log-failure")), fetchOptions)) + .status, + 401, + ); + }); +}); + +it("uses the Fedify KV serialization for RSA, Multikey and negative entries", async () => { + const kv = new MemoryKvStore(); + const cache = createKeyCache(kv); + const rsa = await generateCryptoKeyPair(); + const key = new CryptographicKey({ + id: keyId, + owner: actorIri, + publicKey: rsa.publicKey, + }); + await cache.set(keyId, key); + assert.deepEqual( + await kv.get(["_fedify", "publicKey", "2", keyId.href]), + await key.toJsonLd(), + ); + assert.ok((await cache.get(keyId)) instanceof CryptographicKey); + const ed = await generateCryptoKeyPair("Ed25519"); + const multi = new Multikey({ + id: keyId, + controller: actorIri, + publicKey: ed.publicKey, + }); + await kv.set( + ["_fedify", "publicKey", "2", keyId.href], + await multi.toJsonLd(), + ); + assert.ok((await cache.get(keyId)) instanceof Multikey); + await cache.set(keyId, null); + assert.equal(await cache.get(keyId), null); + await cache.setFetchError(keyId, { error: new TypeError("offline") }); + assert.equal( + ((await cache.getFetchError(keyId)) as { error: Error }).error.name, + "TypeError", + ); + await kv.set(["_fedify", "publicKey", "2", keyId.href], "not a key"); + assert.equal(await cache.get(keyId), undefined); +}); + +it("classifies accepted proofs independently of HTTP signature failure and describes malformed JSON-LD", async () => { + assert.deepEqual(await describeActivity({}), { + type: null, + types: [], + activityIri: null, + remoteActorIri: null, + objectType: null, + objectIri: null, + }); + assert.equal( + (await describeActivity({ type: "Extension", id: "urn:test" })).type, + "Extension", + ); + for (const [statusCode, handled, verificationResult, status] of [ + [202, true, "no_signature", "received"], + [202, true, "invalid_signature", "received"], + [202, false, "verified", "acknowledged"], + [401, false, "verified", "rejected"], + [401, false, "unobserved", "unverified"], + [400, false, "no_signature", "unverified"], + // Handling threw instead of answering. + [null, true, "verified", "rejected"], + [null, false, "unattempted", "unverified"], + ] as const) { + assert.equal( + classifyInbound({ statusCode, handled, verificationResult }), + status, + ); + } + assert.deepEqual( + await describeActivity({ + "@context": 123, + type: "Extension", + id: "urn:test", + actor: ["unknown"], + object: "urn:object", + }), + { + type: "Extension", + types: ["Extension"], + activityIri: "urn:test", + remoteActorIri: null, + objectType: null, + objectIri: null, + }, + ); +}); + +it("paginates tied timestamps, filters logs and reads verification keys as an instance member", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const version = await observeKeyVersion(db, { + iri: keyId.href, + publicKey: { kty: "RSA", e: "AQAB", n: "test" }, + }); + const created = Temporal.Instant.from("2026-09-01T00:00:00.123456Z"); + const rows = []; + for (const status of ["received", "unverified", "rejected"] as const) { + rows.push( + await recordInbound(db, { + ...observed, + instanceId: localInstanceId, + actorId: localActorId, + inboxUrl: inbox, + status, + type: status === "received" ? "Create" : "Follow", + verificationKeyId: version.id, + payload: { bcc: ["private"] }, + created, + }), + ); + } + const outbound = await recordOutbound(db, { + instanceId: localInstanceId, + actorId: localActorId, + inboxUrl: inbox, + activityIri: "https://local.example/activity", + type: "Create", + payload: {}, + created, + }); + const query = `query($id: ID!, $after: String, $filter: ActivityLogFilter) { + node(id: $id) { ... on Instance { activityLogs(first: 2, after: $after, filter: $filter) { + edges { cursor node { uuid direction status type payload verificationKey { fingerprint publicKey key { iri versions { edges { node { uuid } } } } } } } + pageInfo { hasNextPage endCursor } + } } } + }`; + const page = async (after?: string, filter?: Record) => { + const result = await ( + await post( + { + query, + variables: { + id: globalId("Instance", localInstanceId), + after, + filter, + }, + }, + auth, + ) + ).json(); + assert.equal(result.errors, undefined, JSON.stringify(result.errors)); + return result.data.node.activityLogs; + }; + const first = await page(); + const second = await page(first.pageInfo.endCursor); + assert.equal(first.pageInfo.hasNextPage, true); + assert.equal(second.pageInfo.hasNextPage, false); + assert.deepEqual( + [...first.edges, ...second.edges].map( + (edge: { node: { uuid: string } }) => edge.node.uuid, + ), + [outbound, ...rows.toReversed()].map((row) => row.id), + ); + assert.equal(first.edges[1].node.verificationKey.key.iri, keyId.href); + assert.equal( + (await page(undefined, { direction: "outbound" })).edges.length, + 1, + ); + assert.equal( + (await page(undefined, { status: "received" })).edges.length, + 1, + ); + assert.equal((await page(undefined, { type: "Create" })).edges.length, 2); + const actorResult = await ( + await post( + { + query: `{ node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 10) { edges { node { uuid } } } } } }`, + }, + auth, + ) + ).json(); + assert.equal(actorResult.errors, undefined); + assert.equal(actorResult.data.node.activityLogs.edges.length, 4); + }); +}); + +it("denies anonymous/nonmember access including node typename and remote connections", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await seedRemoteActor(db); + const log = await recordInbound(db, { + ...observed, + instanceId: localInstanceId, + actorId: localActorId, + inboxUrl: inbox, + status: "received", + payload: {}, + }); + const logId = Buffer.from(`ActivityLog:${log.id}`).toString("base64"); + const queries = [ + `{ node(id: "${logId}") { __typename } }`, + `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs { pageInfo { hasNextPage } } } } }`, + `{ node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs { pageInfo { hasNextPage } } } } }`, + ]; + for (const query of queries) { + assert.ok((await (await post({ query })).json()).errors?.length); + } + await db + .delete(schema.instanceMembers) + .where(eq(schema.instanceMembers.accountId, accountId)); + for (const query of queries) { + assert.ok((await (await post({ query }, auth)).json()).errors?.length); + } + await db + .update(schema.accounts) + .set({ admin: true }) + .where(eq(schema.accounts.id, accountId)); + assert.equal( + (await (await post({ query: queries[0]! }, auth)).json()).errors, + undefined, + ); + for (const [type, id] of [ + ["Instance", remoteInstanceId], + ["Actor", remoteActorId], + ] as const) { + assert.ok( + ( + await ( + await post( + { + query: `{ node(id: "${globalId(type, id)}") { ... on ${type} { activityLogs { pageInfo { hasNextPage } } } } }`, + }, + auth, + ) + ).json() + ).errors?.length, + ); + } + }); +}); + +/** + * A key of the test's own to deliver with, since local key pairs arrive with + * #87. + * @returns The key pair. + */ +async function testKey(): Promise { + const { privateKey } = await generateCryptoKeyPair("Ed25519"); + return { keyId: new URL(`${actorIri.href}#key`), privateKey }; +} + +it("settles synchronous delivery and retains HTTP failure diagnostics", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + allowPrivateAddress: true, + }); + const ctx = federation.createContext(new URL(inbox), undefined); + const key = await testKey(); + const sender = { identifier: localActorId }; + const fakeContext = ( + sendActivity: Context["sendActivity"], + ): Context => + new Proxy(ctx, { + get(target, property) { + return property === "sendActivity" + ? sendActivity + : Reflect.get(target, property); + }, + }); + // Fedify delivers for real: only its report of the delivery makes it sent. + await withInbox([[202, ""]], async (remote) => { + const recipient = { id: actorIri, inboxId: remote }; + const activity = new Create({ + id: new URL("https://test-instance.drfed.org/activity/1"), + actor: ctx.getActorUri(localActorId), + }); + await deliverActivity( + db, + fakeContext((_sender, recipients, sent) => + ctx.sendActivity(key, recipients as Recipient[], sent), + ), + sender, + [recipient, recipient], + activity, + ); + }); + assert.equal(await db.$count(schema.activityLogs), 1); + const sent = await db.query.activityLogs.findFirst({ + with: { attempts: true }, + }); + assert.equal(sent?.status, "sent"); + assert.equal(sent.statusCode, 202); + assert.ok(sent.completed != null); + assert.deepEqual( + sent.attempts.map((attempt) => attempt.succeeded), + [true], + ); + // Fedify reports a synchronous failure only by throwing it. + const recipient = { + id: actorIri, + inboxId: new URL("https://remote.example/inbox"), + }; + const failure = new SendActivityError( + recipient.inboxId, + 410, + "gone", + "Gone forever", + ); + const failActivity = new Create({ + id: new URL("https://test-instance.drfed.org/activity/2"), + actor: ctx.getActorUri(localActorId), + }); + await assert.rejects( + deliverActivity( + db, + fakeContext(() => Promise.reject(failure)), + sender, + recipient, + failActivity, + ), + SendActivityError, + ); + const failed = await db.query.activityLogs.findFirst({ + where: { activityIri: failActivity.id!.href }, + with: { attempts: true }, + }); + assert.equal(failed?.status, "failed"); + assert.equal(failed?.statusCode, 410); + assert.equal(failed?.error, "gone"); + assert.equal(failed?.responseBody, "Gone forever"); + assert.deepEqual( + failed?.attempts.map((attempt) => [ + attempt.succeeded, + attempt.statusCode, + attempt.error, + attempt.responseBody, + ]), + [[false, 410, "gone", "Gone forever"]], + ); + }); +}); + +it("settles successful inboxes independently from thrown delivery failures", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + allowPrivateAddress: true, + }); + const context = federation.createContext(new URL(inbox), undefined); + const key = await testKey(); + const bad = { + id: actorIri, + inboxId: new URL("https://remote.example/bad"), + }; + const activity = new Create({ + id: new URL("https://test-instance.drfed.org/activity/mixed"), + actor: context.getActorUri(localActorId), + }); + const ctx = new Proxy(context, { + get(target, property) { + if (property !== "sendActivity") return Reflect.get(target, property); + return (_sender: unknown, recipients: Recipient[], sent: Activity) => + recipients[0]?.inboxId?.href === bad.inboxId.href + ? Promise.reject( + new SendActivityError( + bad.inboxId, + 503, + "unavailable", + "Try later", + ), + ) + : target.sendActivity(key, recipients, sent); + }, + }); + await withInbox([[202, ""]], async (remote) => { + const good = { id: actorIri, inboxId: remote }; + await assert.rejects( + deliverActivity( + db, + ctx, + { identifier: localActorId }, + [good, bad], + activity, + ), + SendActivityError, + ); + }); + const rows = await db.query.activityLogs.findMany({ + orderBy: { inboxUrl: "asc" }, + }); + assert.equal(rows[0]?.status, "sent"); + assert.equal(rows[0]?.statusCode, 202); + assert.equal(rows[1]?.status, "failed"); + assert.equal(rows[1]?.statusCode, 503); + assert.equal(rows[1]?.error, "unavailable"); + assert.equal(rows[1]?.responseBody, "Try later"); + }); +}); + +it("returns a literal JSON null payload without nulling its connection", async () => { + await withTestHarness(async ({ db, post }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await recordInbound(db, { + ...observed, + instanceId: localInstanceId, + inboxUrl: inbox, + status: "unverified", + payload: null, + }); + const result = await ( + await post( + { + query: `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node { status payload } } } } } }`, + }, + auth, + ) + ).json(); + assert.equal(result.errors, undefined); + assert.deepEqual(result.data.node.activityLogs.edges[0].node, { + status: "unverified", + payload: null, + }); + }); +}); + +it("skips verification observations for unclaimed hosts and failed instance lookups", async () => { + await withTemporaryDatabase(async (db) => { + const kv = new MemoryKvStore(); + const base = await createFederation(db, { kv }); + const pair = await generateCryptoKeyPair(); + let reads = 0; + const passThrough = new Response("No local instance", { status: 404 }); + const federation = { + createContext: (input: Request, data: unknown) => { + const ctx = base.createContext(input, data); + return new Proxy(ctx, { + get(target, property) { + if (property === "documentLoader") { + return () => { + reads += 1; + return Promise.reject(new Error("Unexpected key lookup")); + }; + } + const value = Reflect.get(target, property); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + }, + fetch: () => Promise.resolve(passThrough), + } as unknown as TrackedFederation; + const recorder = createInboundRecorder({ db, federation, rootOrigin }); + const signed = await signRequest( + request(payload("unknown-host")), + pair.privateKey, + keyId, + ); + assert.equal( + await recorder.fetch(signed.clone(), fetchOptions), + passThrough, + ); + assert.equal(reads, 0); + assert.equal(await db.$count(schema.keys), 0); + assert.equal(await db.$count(schema.keyVersions), 0); + assert.equal(await db.$count(schema.activityLogs), 0); + await db.execute( + "ALTER TABLE instances RENAME TO temporarily_unavailable_instances", + ); + assert.equal( + await recorder.fetch(signed.clone(), fetchOptions), + passThrough, + ); + assert.equal(reads, 0); + assert.equal(await db.$count(schema.keyVersions), 0); + }); +}); diff --git a/packages/graphql/src/activity-log/addressing.ts b/packages/graphql/src/activity-log/addressing.ts new file mode 100644 index 0000000..be22b20 --- /dev/null +++ b/packages/graphql/src/activity-log/addressing.ts @@ -0,0 +1,78 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Database } from "@drfed/models"; +import type { AddressedActor } from "@drfed/models/activity-log"; +import type { Uuid } from "@drfed/models/uuid"; +import { type Activity, PUBLIC_COLLECTION } from "@fedify/vocab"; + +/** + * The IRIs an activity addresses, without Public. + * @returns The distinct addressed IRIs. + */ +export function addressedIris(activity: Activity | null): string[] { + if (activity == null) return []; + const iris = [ + ...activity.toIds, + ...activity.btoIds, + ...activity.ccIds, + ...activity.bccIds, + ...activity.audienceIds, + ].map((iri) => iri.href); + return [...new Set(iris)].filter((iri) => iri !== PUBLIC_COLLECTION.href); +} + +/** + * Resolve addressed IRIs to local actors, directly or through stored + * collection membership. + * @returns One entry per way an actor is reached. + */ +export async function findAddressedActors( + db: Database, + instanceId: Uuid, + iris: readonly string[], +): Promise { + if (iris.length === 0) return []; + const local = { + instanceId, + localId: { isNotNull: true }, + deleted: { isNull: true }, + } as const; + const direct = await db.query.actors.findMany({ + columns: { id: true }, + where: { ...local, resource: { iri: { in: [...iris] } } }, + }); + const collections = await db.query.collections.findMany({ + columns: { id: true }, + where: { resource: { iri: { in: [...iris] } } }, + with: { + resource: { columns: { iri: true } }, + items: { + columns: { itemId: true }, + where: { item: { actor: local } }, + }, + }, + }); + return [ + ...direct.map(({ id }) => ({ actorId: id })), + ...collections.flatMap(({ resource, items }) => + items.map(({ itemId }) => ({ + actorId: itemId, + viaCollectionIri: resource.iri, + })), + ), + ]; +} diff --git a/packages/graphql/src/activity-log/describe.ts b/packages/graphql/src/activity-log/describe.ts new file mode 100644 index 0000000..5f7ff1c --- /dev/null +++ b/packages/graphql/src/activity-log/describe.ts @@ -0,0 +1,133 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Object as APObject, Activity, getTypeId } from "@fedify/vocab"; + +type Options = Parameters[1]; + +function document(value: unknown): Record { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} +/** + * PostgreSQL text holds no U+0000; the original stays in the raw request. + * @returns Whether the value is a string text can hold. + */ +const text = (value: unknown): value is string => + typeof value === "string" && !value.includes("\0"); +const string = (value: unknown): string | null => (text(value) ? value : null); +const strings = (value: unknown): string[] => + (Array.isArray(value) ? value : [value]).filter(text); +const first = (value: unknown): unknown => + Array.isArray(value) ? value[0] : value; + +/** + * Keep an untrusted IRI only when it is an absolute URL text can hold; the + * original stays in the raw request. + * @returns The IRI, or null when it is not a valid URL or holds U+0000. + */ +export const iri = (value: unknown): string | null => + text(value) && URL.canParse(value) ? value : null; + +/** + * Parse JSON-LD as an activity without dereferencing. + * @returns The activity, or null when the document is not one. + */ +export async function parseActivity( + payload: unknown, + options: Options = {}, +): Promise { + try { + // The Activity constructor accepts even {} as a base Activity. Parse the + // actual vocabulary type first so malformed JSON does not invent a type. + const activity = await APObject.fromJsonLd(payload, options); + return activity instanceof Activity ? activity : null; + } catch { + return null; + } +} + +function resolvedType(types: readonly string[], activity: Activity) { + const { href, hash } = getTypeId(activity); + return ( + types.find((type) => type === href || `#${type}` === hash) ?? + types[0] ?? + null + ); +} + +/** + * Describe an already parsed activity. + * @returns Best-effort activity metadata. + */ +export async function describeParsed( + payload: unknown, + activity: Activity | null, + options: Options = {}, +) { + const raw = document(payload); + const types = strings(raw.type ?? raw["@type"]); + if (activity == null) { + return { + type: string(raw.type), + types, + activityIri: iri(raw.id), + remoteActorIri: iri(raw.actor), + objectType: null, + objectIri: null, + }; + } + const value = document( + await activity.toJsonLd({ ...options, format: "compact" }), + ); + const object = document(first(value.object)); + return { + type: string(value.type) ?? resolvedType(types, activity), + types, + activityIri: iri(value.id), + remoteActorIri: activity.actorIds[0]?.href ?? null, + objectType: string(first(object.type)), + objectIri: iri(first(value.object)) ?? iri(object.id), + }; +} + +/** + * Describe JSON-LD without dereferencing its actor or object. + * @returns Best-effort activity metadata. + */ +export async function describeActivity( + payload: unknown, + options: Options = {}, +) { + return await describeParsed( + payload, + await parseActivity(payload, options), + options, + ); +} + +/** + * Best-effort host extraction from untrusted activity metadata. + * @returns The remote host, or null when unavailable. + */ +export function remoteHost( + actorIri: string | null, + keyIri: string | null, +): string | null { + const url = iri(actorIri ?? keyIri); + return url == null ? null : new URL(url).host || null; +} diff --git a/packages/graphql/src/activity-log/entry.ts b/packages/graphql/src/activity-log/entry.ts new file mode 100644 index 0000000..54a354c --- /dev/null +++ b/packages/graphql/src/activity-log/entry.ts @@ -0,0 +1,407 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { + activityLogDirectionEnum, + activityLogStatusEnum, + activityLogVerificationMechanismEnum, + activityLogVerificationResultEnum, +} from "@drfed/models/schema"; +import type { Uuid } from "@drfed/models/uuid"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; + +import builder, { type DrFedObjectRef } from "../builder.ts"; + +export { createKeyCache } from "./keycache.ts"; +export { + classifyInbound, + createInboundRecorder, + parseBody, + recordedHeaders, +} from "./inbound.ts"; +export { describeActivity } from "./describe.ts"; +export { + declaredKeyId, + hasLdSignature, + proofMethods, + reportedVerdict, +} from "./verification.ts"; +export { deliverActivity, groupRecipients } from "./outbound.ts"; +export { failureOf, queuedSettlements } from "./queue.ts"; +export type { ObservedKeyFetch, ObservedSpan } from "./tracking.ts"; + +const ActivityLogDirection = builder.enumType("ActivityLogDirection", { + description: "Whether DrFed received the delivery or made it.", + values: activityLogDirectionEnum.enumValues, +}); +const ActivityLogStatus = builder.enumType("ActivityLogStatus", { + description: + "What became of a delivery. Inbound values follow the response DrFed " + + "gave, a request whose handling threw counting as refused; outbound " + + "values follow the delivery attempt.", + values: { + received: { + description: "Inbound: answered 2xx and the inbox listener ran.", + }, + acknowledged: { + description: + "Inbound: answered 2xx without running the inbox listener, e.g. a " + + "duplicate of an already processed activity or an unsupported type.", + }, + unverified: { + description: "Inbound: refused, and no signature or proof was verified.", + }, + rejected: { + description: + "Inbound: refused although a signature or proof was verified.", + }, + queued: { + description: "Outbound: started, and no attempt has ended yet.", + }, + sent: { description: "Outbound: the remote inbox accepted the delivery." }, + failed: { + description: + "Outbound: the latest attempt failed. With a message queue, it may " + + "still be retried.", + }, + permanently_failed: { + description: + "Outbound: given up without further retries, because the remote " + + "inbox answered with a permanent failure status, or because the " + + "circuit breaker for its host held the delivery too long.", + }, + abandoned: { + description: + "Outbound: the retry policy ran out after the latest attempt failed.", + }, + } satisfies Record<(typeof activityLogStatusEnum.enumValues)[number], object>, +}); +const VerificationMechanism = builder.enumType( + "ActivityLogVerificationMechanism", + { + description: + "How an inbound activity was authenticated. Not to be confused with " + + "FEP-8b32's `verificationMethod`, which is a key.", + values: { + http_signature: { description: "The HTTP request signature." }, + ld_signature: { description: "Linked Data Signatures (`signature`)." }, + object_integrity_proof: { + description: "FEP-8b32 Object Integrity Proofs (`proof`).", + }, + } satisfies Record< + (typeof activityLogVerificationMechanismEnum.enumValues)[number], + object + >, + }, +); +const VerificationResult = builder.enumType("ActivityLogVerificationResult", { + description: + "What Fedify reported of verifying an inbound activity, as it " + + "verified it. Whether the activity was accepted is " + + "`ActivityLog.status`.", + values: { + verified: { description: "A signature or proof was verified." }, + invalid_signature: { + description: "A signature or proof was present and did not verify.", + }, + key_fetch_error: { + description: + "The key a signature or proof names could not be fetched, or what " + + "was fetched held no usable key.", + }, + no_signature: { description: "Nothing to verify was found." }, + unattempted: { + description: + "Fedify answered before verifying anything, e.g. a body that is " + + "not JSON or an unknown inbox.", + }, + unobserved: { + description: + "Recording the observation failed; the mechanism is unknown.", + }, + } satisfies Record< + (typeof activityLogVerificationResultEnum.enumValues)[number], + object + >, +}); +const ActivityLogFilter = builder.inputType("ActivityLogFilter", { + fields: (t) => ({ + direction: t.field({ type: ActivityLogDirection }), + status: t.field({ type: ActivityLogStatus }), + type: t.string(), + }), +}); +function decodeUtf8(body: Uint8Array | null): string | null { + if (body == null) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(body); + } catch { + return null; + } +} +const access = (localId: Uuid | null) => + localId == null + ? false + : { $any: { admin: true as const, localInstanceMember: localId } }; + +builder.drizzleObject("activityLogAttempts", { + name: "ActivityLogAttempt", + description: "One ended attempt of an outbound delivery.", + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + succeeded: t.exposeBoolean("succeeded"), + statusCode: t.exposeInt("statusCode", { + nullable: true, + description: + "The status the remote inbox answered the attempt with. Null " + + "when no response came, as for a network error.", + }), + responseBody: t.exposeString("responseBody", { + nullable: true, + description: + "What the remote inbox answered a failed attempt, with U+FFFD for " + + "each U+0000, which PostgreSQL cannot store.", + }), + error: t.exposeString("error", { + nullable: true, + description: + "Why the attempt failed, including the causes of a network error. " + + "Null for a success.", + }), + created: t.expose("created", { + type: "DateTime", + description: "When the attempt ended.", + }), + }), +}); + +const ActivityLogRef = builder.drizzleNode("activityLogs", { + name: "ActivityLog", + select: { with: { instance: { columns: { localId: true } } } }, + authScopes: (log) => access(log.instance.localId), + runScopesOnType: true, + id: { column: (log) => log.id }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + instance: t.relation("instance"), + actor: t.relation("actor", { + nullable: true, + description: + "The owner of the inbox the request arrived at, or the sending " + + "actor. Null for the shared inbox, and for a deleted actor.", + }), + direction: t.expose("direction", { type: ActivityLogDirection }), + status: t.expose("status", { + type: ActivityLogStatus, + description: + "The outcome of the delivery. Inbound, it follows the response " + + "DrFed gave, not `verificationResult`.", + }), + verificationMechanism: t.expose("verificationMechanism", { + type: VerificationMechanism, + nullable: true, + description: + "Inbound: the mechanism that verified the activity, or the last " + + "one Fedify tried. Null when Fedify tried none, when recording " + + "failed, and for outbound logs.", + }), + verificationResult: t.expose("verificationResult", { + type: VerificationResult, + nullable: true, + description: "Inbound: what verifying found. Null for outbound logs.", + }), + type: t.exposeString("type", { + nullable: true, + description: "The one type the activity resolved to; see `types`.", + }), + types: t.exposeStringList("types", { + description: "Every type the activity declares.", + }), + activityIri: t.expose("activityIri", { type: "URL", nullable: true }), + objectType: t.exposeString("objectType", { nullable: true }), + objectIri: t.expose("objectIri", { type: "URL", nullable: true }), + signedKeyIri: t.expose("signedKeyIri", { + type: "URL", + nullable: true, + description: + "The `keyId` the HTTP signature declares. It may differ from the " + + "IRI of `verificationKey` when another mechanism verified the " + + "activity.", + }), + verificationKey: t.relation("verificationKey", { + nullable: true, + description: + "The public key version `verificationMechanism` actually used, even " + + "when it failed. Its presence does not imply success; consult " + + "`verificationResult`.", + }), + remoteActorIri: t.expose("remoteActorIri", { type: "URL", nullable: true }), + remoteHost: t.exposeString("remoteHost", { nullable: true }), + inboxUrl: t.expose("inboxUrl", { + type: "URL", + description: + "The canonical IRI of the inbox, however the request spelled it; " + + "see `requestUrl`.", + }), + requestUrl: t.expose("requestUrl", { + type: "URL", + nullable: true, + description: "Inbound: the URL the request actually arrived at.", + }), + requestHeaders: t.expose("headers", { + type: "JSON", + nullable: true, + description: + "Inbound: the request headers as `[name, value]` pairs with " + + "lowercase names; their original order and case are not kept. " + + "`cookie` is left out, and `authorization` is whole only for the " + + "`Signature` scheme.", + }), + rawBody: t.string({ + nullable: true, + description: + "Inbound: the request body as received, when it is valid UTF-8; " + + "otherwise read `rawBodyBase64`.", + select: { columns: { body: true } }, + resolve: (log) => decodeUtf8(log.body), + }), + rawBodyBase64: t.string({ + nullable: true, + description: "Inbound: the octets of the request body, in Base64.", + select: { columns: { body: true } }, + resolve: (log) => log.body?.toString("base64") ?? null, + }), + statusCode: t.exposeInt("statusCode", { + nullable: true, + description: + "Inbound: what DrFed answered; null when handling the request " + + "threw. Outbound: what the remote inbox answered the latest " + + "attempt; null when no response came.", + }), + responseBody: t.exposeString("responseBody", { + nullable: true, + description: + "Inbound: what DrFed answered; null when handling the request " + + "threw. Outbound: what the remote inbox answered the latest failed " + + "attempt, with U+FFFD for each U+0000, which PostgreSQL cannot store.", + }), + error: t.exposeString("error", { + nullable: true, + description: + "Why a refused or failed delivery ended that way, which for an " + + "inbound request whose handling threw is the exception. Null for " + + "accepted ones.", + }), + attempts: t.relation("attempts", { + query: { orderBy: { created: "asc", id: "asc" } }, + description: + "Outbound: each attempt that ended, oldest first, including the " + + "retries of a queued delivery. Empty for inbound logs.", + }), + payload: t.expose("payload", { + type: "JSON", + nullable: true, + description: + "Inbound: `rawBody` parsed as JSON, which loses key order, " + + "whitespace and duplicate keys; null when it does not parse, when " + + "it holds U+0000 or an unpaired surrogate, which PostgreSQL cannot " + + "store, and for a literal JSON null. Outbound: the compact JSON-LD " + + "before signing, without `bto` and `bcc`, so it lacks the `proof` " + + "and `signature` the remote server received. May contain " + + "unverified remote input and private recipients.", + }), + recipientIris: t.expose("recipientIris", { + type: ["URL"], + description: + "Outbound: every recipient sharing the inbox, including those " + + "named by `bto` and `bcc`.", + }), + created: t.expose("created", { + type: "DateTime", + description: + "Inbound: when the request arrived, before it was verified and " + + "handled. Outbound: when DrFed started the delivery. Logs are " + + "ordered by it.", + }), + completed: t.expose("completed", { + type: "DateTime", + nullable: true, + description: + "Inbound: when DrFed answered the request. Outbound: when " + + "`status` last changed. Null while an outbound delivery is " + + "`queued`.", + }), + }), +}); +export const ActivityLog: DrFedObjectRef = ActivityLogRef; + +const logsConnection = drizzleConnectionHelpers(builder, "activityLogs", { + query: ({ + filter, + }: { + filter?: typeof ActivityLogFilter.$inferInput | null; + }) => ({ + where: { + ...(filter?.direction == null ? {} : { direction: filter.direction }), + ...(filter?.status == null ? {} : { status: filter.status }), + ...(filter?.type == null ? {} : { type: filter.type }), + }, + orderBy: { created: "desc", id: "desc" }, + }), +}); +builder.drizzleObjectField("instances", "activityLogs", (t) => + t.connection({ + type: ActivityLog, + args: { filter: t.arg({ type: ActivityLogFilter }) }, + description: + "Delivery observations, newest first. " + + "Restricted to local instance members and administrators.", + select: (args, ctx, nestedSelection) => + ({ + columns: { localId: true }, + with: { + activityLogs: logsConnection.getQuery(args, ctx, nestedSelection), + }, + }) as const, + resolve: (instance, args, ctx) => + logsConnection.resolve(instance.activityLogs, args, ctx, instance), + authScopes: (instance) => access(instance.localId), + }), +); +builder.drizzleObjectField("actors", "activityLogs", (t) => + t.connection({ + type: ActivityLog, + args: { filter: t.arg({ type: ActivityLogFilter }) }, + description: + "Deliveries that concern this local actor, newest first: those that " + + "arrived at its inbox, those it sent, and those addressed to it " + + "through any inbox, directly or as a member of an addressed " + + "collection. Collection membership counts only as far as DrFed has " + + "stored it. Restricted to instance members and administrators.", + select: (args, ctx, nestedSelection) => + ({ + columns: { localId: true }, + with: { + instance: { columns: { localId: true } }, + activityLogs: logsConnection.getQuery(args, ctx, nestedSelection), + }, + }) as const, + resolve: (actor, args, ctx) => + logsConnection.resolve(actor.activityLogs, args, ctx, actor), + authScopes: (actor) => + actor.localId == null ? false : access(actor.instance.localId), + }), +); diff --git a/packages/graphql/src/activity-log/inbound.test.ts b/packages/graphql/src/activity-log/inbound.test.ts new file mode 100644 index 0000000..4822b50 --- /dev/null +++ b/packages/graphql/src/activity-log/inbound.test.ts @@ -0,0 +1,1725 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable no-await-in-loop max-statements +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { + type ObservedKeyFetch, + type ObservedSpan, + createInboundRecorder, + declaredKeyId, + hasLdSignature, + parseBody, + recordedHeaders, + reportedVerdict, +} from "@drfed/graphql/activity-log"; +import createFederation, { + type TrackedFederation, +} from "@drfed/graphql/federation"; +import { instanceUrl } from "@drfed/graphql/origin"; +import { type Database, addActorCollectionItem, schema } from "@drfed/models"; +import { + type FederationFetchOptions, + InProcessMessageQueue, + MemoryKvStore, + exportJwk, + generateCryptoKeyPair, + signJsonLd, + signObject, + signRequest, +} from "@fedify/fedify"; +import { FetchError } from "@fedify/fedify/runtime"; +import { + Create, + CryptographicKey, + type DocumentLoader, + Multikey, + Note, + Person, +} from "@fedify/vocab"; +import { eq } from "drizzle-orm"; + +import { withTemporaryDatabase, withTestHarness } from "../harness.test.ts"; +import { + globalId, + localActorId, + localInstanceId, + remoteActorId, + seedAuthenticatedLocalInstance, + seedLocalActor, + seedRemoteActor, +} from "../seed.test.ts"; + +const origin = "https://test-instance.drfed.org"; +const localActorIri = `${origin}/users/${localActorId}`; +const inbox = `${localActorIri}/inbox`; +const sharedInbox = `${origin}/inbox`; +const actorIri = new URL("https://remote.example/users/alice"); +const httpKeyId = new URL(`${actorIri.href}#main-key`); +const ldKeyId = new URL(`${actorIri.href}#ld-key`); +const proofKeyId = new URL(`${actorIri.href}#proof-key`); +const rootOrigin = new URL("https://drfed.org"); +const fetchOptions = { contextData: undefined }; + +const activity = (id: string, extra: Record = {}) => ({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://remote.example/activities/${id}`, + type: "Create", + actor: actorIri.href, + object: { id: `https://remote.example/notes/${id}`, type: "Note" }, + ...extra, +}); +const post = ( + body: string | Uint8Array, + url = inbox, + headers = {}, +) => + new Request(url, { + method: "POST", + headers: { "content-type": "application/activity+json", ...headers }, + body, + }); + +function keyLoader( + keys: ReadonlyMap, +): DocumentLoader { + return async (url) => { + const key = keys.get(url); + if (key == null && url !== actorIri.href) throw new TypeError("offline"); + const document = + key == null + ? await new Person({ + id: actorIri, + publicKeys: [...keys.values()].filter( + (value) => value instanceof CryptographicKey, + ), + assertionMethods: [...keys.values()].filter( + (value) => value instanceof Multikey, + ), + }).toJsonLd() + : await key.toJsonLd(); + return { documentUrl: url, contextUrl: null, document }; + }; +} + +async function createRecorder( + db: Database, + keys: ReadonlyMap = new Map(), + { kv = new MemoryKvStore(), documentLoader = keyLoader(keys) } = {}, +) { + const { contextLoader } = (await createFederation(db, { kv })).createContext( + new URL(inbox), + undefined, + ); + const federation = await createFederation(db, { + kv, + contextLoaderFactory: () => contextLoader, + documentLoaderFactory: () => documentLoader, + }); + const recorder = createInboundRecorder({ db, federation, rootOrigin }); + return { + contextLoader, + send: (request: Request) => recorder.fetch(request, fetchOptions), + }; +} +const logs = (db: Database) => + db.query.activityLogs.findMany({ + orderBy: { id: "asc" }, + with: { verificationKey: { with: { key: true } }, actorLinks: true }, + }); + +it("records the key of the Linked Data Signature that verified, not of the HTTP signature", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const http = await generateCryptoKeyPair(); + const ld = await generateCryptoKeyPair(); + const { contextLoader, send } = await createRecorder( + db, + new Map([ + [ + ldKeyId.href, + new CryptographicKey({ + id: ldKeyId, + owner: actorIri, + publicKey: ld.publicKey, + }), + ], + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: http.publicKey, + }), + ], + ]), + ); + const signed = await signJsonLd(activity("ld"), ld.privateKey, ldKeyId, { + contextLoader, + }); + assert.ok(hasLdSignature(signed)); + const response = await send( + await signRequest( + post(JSON.stringify(signed)), + http.privateKey, + httpKeyId, + ), + ); + assert.equal(response.status, 202); + const [log] = await logs(db); + assert.equal(log?.status, "received"); + assert.equal(log?.verificationMechanism, "ld_signature"); + assert.equal(log?.verificationResult, "verified"); + assert.equal(log?.verificationKey?.key.iri, ldKeyId.href); + assert.equal(log?.signedKeyIri, httpKeyId.href); + assert.equal(await db.$count(schema.keys), 1); + const broken = { ...signed, id: "https://remote.example/activities/x" }; + assert.equal((await send(post(JSON.stringify(broken)))).status, 401); + const failed = (await logs(db))[1]; + assert.equal(failed?.status, "unverified"); + assert.equal(failed?.verificationMechanism, "ld_signature"); + assert.equal(failed?.verificationResult, "invalid_signature"); + assert.equal(failed?.signedKeyIri, null); + }); +}); + +it("records an Object Integrity Proof, and acknowledges a duplicate without receiving it again", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const pair = await generateCryptoKeyPair("Ed25519"); + const { contextLoader, send } = await createRecorder( + db, + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ); + const signed = await signObject( + new Create({ + id: new URL("https://remote.example/activities/proof"), + actor: actorIri, + object: new Note({ id: new URL("https://remote.example/notes/proof") }), + }), + pair.privateKey, + proofKeyId, + { contextLoader }, + ); + const body = JSON.stringify( + await signed.toJsonLd({ format: "compact", contextLoader }), + ); + assert.equal((await send(post(body))).status, 202); + assert.equal((await send(post(body))).status, 202); + const [first, second] = await logs(db); + for (const log of [first, second]) { + assert.equal(log?.verificationMechanism, "object_integrity_proof"); + assert.equal(log?.verificationResult, "verified"); + assert.equal(log?.verificationKey?.key.iri, proofKeyId.href); + assert.equal(log?.signedKeyIri, null); + assert.equal(log?.error, null); + } + assert.equal(first?.status, "received"); + assert.equal(second?.status, "acknowledged"); + assert.match(second?.responseBody ?? "", /already been processed/u); + }); +}); + +it("records the key Fedify verified with, whatever a later fetch returns", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const [first, rotated] = [ + await generateCryptoKeyPair(), + await generateCryptoKeyPair(), + ]; + const key = ({ publicKey }: CryptoKeyPair) => + new CryptographicKey({ id: httpKeyId, owner: actorIri, publicKey }); + const modulus = async ({ publicKey }: CryptoKeyPair) => + (await exportJwk(publicKey)).n; + // The key IRI serves the first key once, and the rotated one after. + const keys = new Map([[httpKeyId.href, key(first)]]); + let fetched = 0; + const serve = keyLoader(keys); + const kv = new MemoryKvStore(); + const { send } = await createRecorder(db, keys, { + kv, + documentLoader: async (url) => { + const document = await serve(url); + if (url === httpKeyId.href) { + fetched += 1; + keys.set(httpKeyId.href, key(rotated)); + } + return document; + }, + }); + const signed = () => + signRequest( + post(JSON.stringify(activity(`rotated-${fetched}`))), + rotated.privateKey, + httpKeyId, + ); + // Fedify fetches the first key once, and it does not verify. + const refused = await send(await signed()); + assert.equal(refused.status, 401); + assert.equal(fetched, 1); + // Cached now, it fails again; Fedify refetches, and the rotated key verifies. + const accepted = await send(await signed()); + assert.equal(accepted.status, 202); + assert.equal(fetched, 2); + const [refusal, acceptance] = await logs(db); + assert.equal(refusal?.status, "unverified"); + assert.equal(refusal?.verificationResult, "invalid_signature"); + assert.equal(refusal?.verificationKey?.publicKey.n, await modulus(first)); + assert.equal(acceptance?.status, "received"); + assert.equal(acceptance?.verificationResult, "verified"); + assert.equal( + acceptance?.verificationKey?.publicKey.n, + await modulus(rotated), + ); + }); +}); + +it("records the key Fedify verified with, even when fetching it again failed", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const [cached, rotated] = [ + await generateCryptoKeyPair(), + await generateCryptoKeyPair(), + ]; + const keys = new Map([ + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: cached.publicKey, + }), + ], + ]); + const serve = keyLoader(keys); + let online = true; + const { send } = await createRecorder(db, keys, { + documentLoader: async (url) => { + if (!online) throw new TypeError("offline"); + return await serve(url); + }, + }); + const signed = (id: string, { privateKey }: CryptoKeyPair) => + signRequest(post(JSON.stringify(activity(id))), privateKey, httpKeyId); + assert.equal((await send(await signed("cached", cached))).status, 202); + // The cached key does not verify, and fetching it again fails. + online = false; + assert.equal((await send(await signed("offline", rotated))).status, 401); + const [, refusal] = await logs(db); + assert.equal(refusal?.status, "unverified"); + assert.equal(refusal?.verificationResult, "key_fetch_error"); + assert.equal( + refusal?.verificationKey?.publicKey.n, + (await exportJwk(cached.publicKey)).n, + ); + }); +}); + +it("records a signature or proof sent to an unknown inbox as unattempted", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { contextLoader, send } = await createRecorder(db); + const proven = await ( + await signObject( + new Create({ + id: new URL("https://remote.example/activities/unknown-proof"), + actor: actorIri, + object: new Note({ + id: new URL("https://remote.example/notes/unknown-proof"), + }), + }), + (await generateCryptoKeyPair("Ed25519")).privateKey, + proofKeyId, + { contextLoader }, + ) + ).toJsonLd({ format: "compact", contextLoader }); + const signed = await signJsonLd( + activity("unknown-ld"), + (await generateCryptoKeyPair()).privateKey, + ldKeyId, + { contextLoader }, + ); + const unknown = `${origin}/users/00000000-0000-4000-8000-000000000299/inbox`; + for (const document of [proven, signed]) { + const response = await send(post(JSON.stringify(document), unknown)); + assert.equal(response.status, 404); + } + const recorded = await logs(db); + assert.equal(recorded.length, 2); + for (const log of recorded) { + assert.equal(log.verificationMechanism, null); + assert.equal(log.verificationResult, "unattempted"); + assert.equal(log.verificationKey, null); + } + }); +}); + +it("keeps a delivery whose JSON PostgreSQL cannot store, with its octets", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const pair = await generateCryptoKeyPair(); + const { send } = await createRecorder( + db, + new Map([ + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ); + const signed = [ + JSON.stringify(activity("nul", { summary: "\u0000" })), + JSON.stringify(activity("surrogate", { summary: "\ud800" })), + ]; + for (const body of signed) { + const request = await signRequest(post(body), pair.privateKey, httpKeyId); + assert.equal((await send(request)).status, 202); + } + // Nor does text hold NUL, whatever an activity that does not parse names. + const unparsed = JSON.stringify({ + type: "Create\u0000", + id: `${actorIri.href}/activities/\u0000`, + actor: `${actorIri.href}\u0000`, + }); + await send(post(unparsed)); + const recorded = await logs(db); + assert.deepEqual( + recorded.map((log) => new TextDecoder().decode(log.body ?? undefined)), + [...signed, unparsed], + ); + for (const log of recorded) assert.equal(log.payload, null); + const [nul, surrogate, raw] = recorded; + for (const log of [nul, surrogate]) { + assert.equal(log?.status, "received"); + assert.equal(log?.verificationResult, "verified"); + assert.equal(log?.statusCode, 202); + } + assert.equal(nul?.activityIri, "https://remote.example/activities/nul"); + assert.deepEqual( + [raw?.type, raw?.types, raw?.activityIri, raw?.remoteActorIri], + [null, [], null, null], + ); + }); +}); + +it("records a request Fedify throws on, and throws the exception again", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const pair = await generateCryptoKeyPair(); + const kv = new MemoryKvStore(); + const { contextLoader } = ( + await createFederation(db, { kv }) + ).createContext(new URL(inbox), undefined); + const context = "https://remote.example/context"; + const federation = await createFederation(db, { + kv, + // The remote context does not load, whoever asks for it. + contextLoaderFactory: () => (url, options) => + url === context + ? Promise.reject(new TypeError("offline")) + : contextLoader(url, options), + documentLoaderFactory: () => + keyLoader( + new Map([ + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ), + }); + const recorder = createInboundRecorder({ db, federation, rootOrigin }); + const body = JSON.stringify( + activity("thrown", { + "@context": ["https://www.w3.org/ns/activitystreams", context], + }), + ); + const request = await signRequest(post(body), pair.privateKey, httpKeyId); + await assert.rejects(recorder.fetch(request, fetchOptions), { + name: "jsonld.InvalidUrl", + }); + const [log, ...rest] = await logs(db); + assert.deepEqual(rest, []); + assert.deepEqual( + [log?.status, log?.statusCode, log?.responseBody], + ["unverified", null, null], + ); + assert.match(log?.error ?? "", /^jsonld\.InvalidUrl: /u); + assert.equal(new TextDecoder().decode(log?.body ?? undefined), body); + assert.equal(log?.activityIri, "https://remote.example/activities/thrown"); + assert.equal(log?.verificationResult, "unattempted"); + assert.equal(log?.signedKeyIri, httpKeyId.href); + assert.ok(log?.completed != null); + }); +}); + +it("records the key of an Object Integrity Proof that fails, and of one keyed by its full IRI", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const pair = await generateCryptoKeyPair("Ed25519"); + const { contextLoader, send } = await createRecorder( + db, + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ); + const sign = async (id: string) => + (await ( + await signObject( + new Create({ + id: new URL(`https://remote.example/activities/${id}`), + actor: actorIri, + object: new Note({ + id: new URL(`https://remote.example/notes/${id}`), + content: "original", + }), + }), + pair.privateKey, + proofKeyId, + { contextLoader }, + ) + ).toJsonLd({ format: "compact", contextLoader })) as Record< + string, + unknown + >; + const tampered = await sign("tampered"); + const object = tampered.object as Record; + tampered.object = { ...object, content: "tampered" }; + assert.equal((await send(post(JSON.stringify(tampered)))).status, 401); + const { proof, ...expanded } = await sign("expanded"); + const full = { ...expanded, "https://w3id.org/security#proof": proof }; + assert.equal((await send(post(JSON.stringify(full)))).status, 202); + const [failed, verified] = await logs(db); + assert.equal(failed?.verificationMechanism, "object_integrity_proof"); + assert.equal(failed?.verificationResult, "invalid_signature"); + assert.equal(failed?.verificationKey?.key.iri, proofKeyId.href); + assert.equal(verified?.status, "received"); + assert.equal(verified?.verificationMechanism, "object_integrity_proof"); + assert.equal(verified?.verificationResult, "verified"); + assert.equal(verified?.verificationKey?.key.iri, proofKeyId.href); + }); +}); + +it("tells a key that could not be fetched from a signature that did not verify", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const rsa = await generateCryptoKeyPair(); + const ed = await generateCryptoKeyPair("Ed25519"); + // Every key is of a server that fails as the fragment of its IRI says. + const keyIri = (mechanism: string, failure: string) => + new URL(`${actorIri.href}#${mechanism}-${failure}`); + const { contextLoader, send } = await createRecorder(db, new Map(), { + documentLoader: async (url) => { + if (url.endsWith("-gone")) { + throw new FetchError(url, "Gone", new Response("", { status: 410 })); + } + if (!url.endsWith("-invalid")) throw new TypeError("offline"); + const document = await new Note({ id: new URL(url) }).toJsonLd(); + return { documentUrl: url, contextUrl: null, document }; + }, + }); + const requests = { + ld_signature: async (id: string, key: URL) => + post( + JSON.stringify( + await signJsonLd(activity(id), rsa.privateKey, key, { + contextLoader, + }), + ), + ), + object_integrity_proof: async (id: string, key: URL) => { + const signed = await signObject( + new Create({ + id: new URL(`https://remote.example/activities/${id}`), + actor: actorIri, + object: new Note({ + id: new URL(`https://remote.example/notes/${id}`), + }), + }), + ed.privateKey, + key, + { contextLoader }, + ); + return post( + JSON.stringify( + await signed.toJsonLd({ format: "compact", contextLoader }), + ), + ); + }, + http_signature: (id: string, key: URL) => + signRequest(post(JSON.stringify(activity(id))), rsa.privateKey, key), + }; + // Fedify names why an HTTP signature's key was not fetched itself. + const causes = { + ld_signature: ["network_error", "cached", "410", "invalid"], + object_integrity_proof: ["network_error", "cached", "410", "invalid"], + http_signature: ["TypeError", "TypeError", "410", "invalid"], + }; + for (const mechanism of [ + "ld_signature", + "object_integrity_proof", + "http_signature", + ] as const) { + const sign = requests[mechanism]; + // The second request finds the failure of the first in the cache. + for (const [index, failure] of [ + "offline", + "offline", + "gone", + "invalid", + ].entries()) { + const id = `${mechanism}-${failure}-${index}`; + const response = await send(await sign(id, keyIri(mechanism, failure))); + assert.equal(response.status, 401, id); + const log = (await logs(db)).at(-1); + assert.equal(log?.status, "unverified", id); + assert.equal(log?.verificationMechanism, mechanism, id); + assert.equal(log?.verificationResult, "key_fetch_error", id); + assert.equal(log?.verificationKey, null, id); + assert.equal( + log?.error, + `keyFetchError: ${causes[mechanism][index]}`, + id, + ); + } + } + }); +}); + +it("records the key a signature or proof failed with, when fetching it again failed", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const rsa = [await generateCryptoKeyPair(), await generateCryptoKeyPair()]; + const ed = [ + await generateCryptoKeyPair("Ed25519"), + await generateCryptoKeyPair("Ed25519"), + ]; + const keys = new Map([ + [ + ldKeyId.href, + new CryptographicKey({ + id: ldKeyId, + owner: actorIri, + publicKey: rsa[0]!.publicKey, + }), + ], + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: ed[0]!.publicKey, + }), + ], + ]); + const serve = keyLoader(keys); + let online = true; + const { contextLoader, send } = await createRecorder(db, keys, { + documentLoader: async (url) => { + if (!online) throw new TypeError("offline"); + return await serve(url); + }, + }); + const ld = async (id: string, { privateKey }: CryptoKeyPair) => + post( + JSON.stringify( + await signJsonLd(activity(id), privateKey, ldKeyId, { + contextLoader, + }), + ), + ); + const proof = async (id: string, { privateKey }: CryptoKeyPair) => { + const signed = await signObject( + new Create({ + id: new URL(`https://remote.example/activities/${id}`), + actor: actorIri, + object: new Note({ + id: new URL(`https://remote.example/notes/${id}`), + }), + }), + privateKey, + proofKeyId, + { contextLoader }, + ); + return post( + JSON.stringify( + await signed.toJsonLd({ format: "compact", contextLoader }), + ), + ); + }; + assert.equal((await send(await ld("ld-cached", rsa[0]!))).status, 202); + assert.equal((await send(await proof("proof-cached", ed[0]!))).status, 202); + // The cached keys do not verify, and fetching them again fails. + online = false; + assert.equal((await send(await ld("ld-offline", rsa[1]!))).status, 401); + assert.equal( + (await send(await proof("proof-offline", ed[1]!))).status, + 401, + ); + const [, , ldRefusal, proofRefusal] = await logs(db); + for (const [refusal, mechanism, keyId] of [ + [ldRefusal, "ld_signature", ldKeyId], + [proofRefusal, "object_integrity_proof", proofKeyId], + ] as const) { + assert.equal(refusal?.verificationMechanism, mechanism); + assert.equal(refusal?.verificationResult, "key_fetch_error"); + assert.equal(refusal?.error, "keyFetchError: network_error"); + assert.equal(refusal?.verificationKey?.key.iri, keyId.href); + } + assert.equal( + ldRefusal?.verificationKey?.publicKey.n, + (await exportJwk(rsa[0]!.publicKey)).n, + ); + }); +}); + +it("records the key each verification used, not one another found under the same IRI", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const rsa = await generateCryptoKeyPair(); + const ed = await generateCryptoKeyPair("Ed25519"); + // The proof and the HTTP signature name one IRI, which serves a Multikey. + const { contextLoader, send } = await createRecorder( + db, + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: ed.publicKey, + }), + ], + ]), + ); + const signed = (await ( + await signObject( + new Create({ + id: new URL("https://remote.example/activities/shared-iri"), + actor: actorIri, + object: new Note({ + id: new URL("https://remote.example/notes/shared-iri"), + content: "original", + }), + }), + ed.privateKey, + proofKeyId, + { contextLoader }, + ) + ).toJsonLd({ format: "compact", contextLoader })) as Record< + string, + unknown + >; + const object = signed.object as Record; + const tampered = { ...signed, object: { ...object, content: "tampered" } }; + const request = await signRequest( + post(JSON.stringify(tampered)), + rsa.privateKey, + proofKeyId, + ); + assert.equal((await send(request)).status, 401); + const [log] = await logs(db); + assert.equal(log?.status, "unverified"); + assert.equal(log?.verificationMechanism, "http_signature"); + assert.equal(log?.verificationResult, "key_fetch_error"); + assert.equal(log?.error, "keyFetchError: invalid"); + assert.equal(log?.signedKeyIri, proofKeyId.href); + // The proof read the Ed25519 key; the HTTP signature found none to use. + assert.equal(log?.verificationKey, null); + // A Linked Data Signature and the HTTP signature name one key, too. + const [cached, rotated] = [ + await generateCryptoKeyPair(), + await generateCryptoKeyPair(), + ]; + const keys = new Map([ + [ + httpKeyId.href, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: cached.publicKey, + }), + ], + ]); + const serve = keyLoader(keys); + let online = true; + const shared = await createRecorder(db, keys, { + documentLoader: async (url) => { + if (!online) throw new TypeError("offline"); + return await serve(url); + }, + }); + const ld = async (id: string, { privateKey }: CryptoKeyPair) => + post( + JSON.stringify( + await signJsonLd(activity(id), privateKey, httpKeyId, { + contextLoader: shared.contextLoader, + }), + ), + ); + const accepted = await shared.send(await ld("shared-cached", cached)); + assert.equal(accepted.status, 202); + // The former fails with the cached key, and empties the entry fetching it + // again; the latter then finds no key, and so uses none. + online = false; + const both = await signRequest( + await ld("shared-offline", rotated), + rotated.privateKey, + httpKeyId, + ); + assert.equal((await shared.send(both)).status, 401); + const refusal = (await logs(db)).at(-1); + assert.equal(refusal?.verificationMechanism, "http_signature"); + assert.equal(refusal?.verificationResult, "key_fetch_error"); + assert.equal(refusal?.signedKeyIri, httpKeyId.href); + assert.equal(refusal?.verificationKey, null); + }); +}); + +it("records a proof that verified as verified, though it does not authenticate the actor", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const rsa = await generateCryptoKeyPair(); + const ed = await generateCryptoKeyPair("Ed25519"); + const { contextLoader, send } = await createRecorder( + db, + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: ed.publicKey, + }), + ], + ]), + ); + // Alice's key signs an activity that names Bob as its actor. + const prove = async (id: string) => + JSON.stringify( + await ( + await signObject( + new Create({ + id: new URL(`https://remote.example/activities/${id}`), + actor: new URL("https://remote.example/users/bob"), + object: new Note({ + id: new URL(`https://remote.example/notes/${id}`), + }), + }), + ed.privateKey, + proofKeyId, + { contextLoader }, + ) + ).toJsonLd({ format: "compact", contextLoader }), + ); + assert.equal((await send(post(await prove("bob")))).status, 401); + // Nor does an HTTP signature that fails make the proof one that failed. + const unsigned = await signRequest( + post(await prove("bob-http")), + rsa.privateKey, + httpKeyId, + ); + assert.equal((await send(unsigned)).status, 401); + const recorded = await logs(db); + assert.equal(recorded.length, 2); + for (const log of recorded) { + assert.equal(log.status, "rejected"); + assert.equal(log.verificationMechanism, "object_integrity_proof"); + assert.equal(log.verificationResult, "verified"); + assert.equal(log.verificationKey?.key.iri, proofKeyId.href); + assert.equal(log.verificationKey?.publicKey.kty, "OKP"); + assert.match(log.error ?? "", /did not accept them as authenticating/u); + } + assert.deepEqual( + recorded.map((log) => log.signedKeyIri), + [null, httpKeyId.href], + ); + }); +}); + +it("receives a queued activity once the queue worker runs its listener", async () => { + for (const workerFirst of [false, true]) { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const pair = await generateCryptoKeyPair("Ed25519"); + const kv = new MemoryKvStore(); + const { contextLoader } = ( + await createFederation(db, { kv }) + ).createContext(new URL(inbox), undefined); + const federation = await createFederation(db, { + kv, + queue: new InProcessMessageQueue(), + manuallyStartQueue: true, + contextLoaderFactory: () => contextLoader, + documentLoaderFactory: () => + keyLoader( + new Map([ + [ + proofKeyId.href, + new Multikey({ + id: proofKeyId, + controller: actorIri, + publicKey: pair.publicKey, + }), + ], + ]), + ), + }); + const controller = new AbortController(); + const start = () => + federation.startQueue(undefined, { signal: controller.signal }); + // Holding the recorder back lets the worker finish before the log exists. + const { promise: held, resolve: release } = Promise.withResolvers(); + const recorder = createInboundRecorder({ + db: workerFirst + ? new Proxy(db, { + get(target, property) { + const value: unknown = Reflect.get(target, property, target); + if (property !== "transaction" || typeof value !== "function") { + return value; + } + return async (...args: unknown[]) => { + await held; + return (value as (...values: unknown[]) => unknown).apply( + target, + args, + ); + }; + }, + }) + : db, + federation, + rootOrigin, + }); + const signed = await signObject( + new Create({ + id: new URL("https://remote.example/activities/queued"), + actor: actorIri, + object: new Note({ id: new URL("https://remote.example/notes/q") }), + }), + pair.privateKey, + proofKeyId, + { contextLoader }, + ); + const body = JSON.stringify( + await signed.toJsonLd({ format: "compact", contextLoader }), + ); + const worker = workerFirst ? start() : undefined; + const response = recorder.fetch(post(body), fetchOptions); + if (workerFirst) { + await sleep(300); + release(); + } + assert.equal((await response).status, 202); + const running = worker ?? start(); + try { + let status: string | undefined; + for (let tries = 0; tries < 300 && status !== "received"; tries += 1) { + await sleep(10); + status = (await db.query.activityLogs.findFirst())?.status; + } + assert.equal(status, "received", `worker first: ${workerFirst}`); + } finally { + controller.abort(); + await running; + } + }); + } +}); + +it("touches neither the database nor the body of a request that is not an inbox POST", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const kv = new MemoryKvStore(); + const base = await createFederation(db, { kv }); + let queries = 0; + const counting = new Proxy(db, { + get(target, property) { + queries += 1; + return Reflect.get(target, property); + }, + }); + const passThrough = new Response("passed through"); + const federation = { + createContext: base.createContext.bind(base), + fetch: () => Promise.resolve(passThrough), + } as unknown as TrackedFederation; + const recorder = createInboundRecorder({ + db: counting, + federation, + rootOrigin, + }); + const requests = [ + new Request(localActorIri), + new Request(inbox), + post("{}", `${origin}/users/${localActorId}/outbox`), + ]; + for (const request of requests) { + assert.equal(await recorder.fetch(request, fetchOptions), passThrough); + assert.equal(request.bodyUsed, false); + } + assert.equal(queries, 0); + assert.equal(await db.$count(schema.activityLogs), 0); + }); +}); + +it("keeps the received octets and headers while parsing a payload for querying", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { send } = await createRecorder(db); + const body = `{ "@context": "https://www.w3.org/ns/activitystreams", "id": "urn:dup", "type":"Create", "actor": "${actorIri.href}", "summary": "a", "summary": "b" }`; + assert.equal( + ( + await send( + post(body, inbox, { + cookie: "session=secret", + authorization: "Bearer secret", + }), + ) + ).status, + 401, + ); + const invalidUtf8 = new Uint8Array([0x7b, 0xff, 0x7d]); + assert.equal((await send(post(invalidUtf8))).status, 400); + const [duplicated, binary] = await logs(db); + assert.equal(new TextDecoder().decode(duplicated?.body ?? undefined), body); + assert.deepEqual(duplicated?.payload, { + "@context": "https://www.w3.org/ns/activitystreams", + id: "urn:dup", + type: "Create", + actor: actorIri.href, + summary: "b", + }); + const headers = new Map(duplicated?.headers); + assert.equal(headers.get("content-type"), "application/activity+json"); + assert.equal(headers.get("authorization"), "Bearer"); + assert.equal(headers.has("cookie"), false); + assert.deepEqual(new Uint8Array(binary?.body ?? []), invalidUtf8); + assert.equal(binary?.statusCode, 400); + assert.equal(binary?.status, "unverified"); + assert.equal(binary?.responseBody, binary?.error); + }); + assert.equal(parseBody(new TextEncoder().encode("nope")), undefined); + assert.equal(parseBody(new TextEncoder().encode("null")), null); + const signature = + 'Signature keyId="https://remote.example/key",headers="date"'; + assert.deepEqual( + recordedHeaders(new Headers({ Authorization: signature, Cookie: "a=b" })), + [["authorization", signature]], + ); + assert.equal( + declaredKeyId(new Headers({ authorization: signature })), + "https://remote.example/key", + ); + assert.equal( + declaredKeyId( + new Headers({ "signature-input": 'sig1=("@method");keyid="urn:k"' }), + ), + "urn:k", + ); + assert.equal(declaredKeyId(new Headers({ authorization: "Bearer x" })), null); +}); + +it("reads the verification out of what Fedify reports", () => { + const span = ( + name: string, + attributes: Record = {}, + { failed = false, events = [] as ObservedSpan["events"] } = {}, + ): ObservedSpan => ({ + name, + attributes: new Map(Object.entries(attributes)), + events, + failed, + }); + const measured = ( + kind: string, + result: string, + keyFetches: readonly ObservedKeyFetch[] = [], + ) => ({ kind, result, keyFetches }); + const none = { ldKeyIri: null, proofMethods: [] }; + const proofKey = proofKeyId.href; + const noSignature = span("http_signatures.verify", { + "http_signatures.verified": false, + "http_signatures.failure_reason": "noSignature", + }); + // Answered before verifying, e.g. a body that is not JSON. + assert.deepEqual(reportedVerdict({ spans: [], verifications: [] }, none), { + mechanism: null, + result: "unattempted", + }); + // Linked Data Signatures verified, even if the activity then did not parse. + assert.deepEqual( + reportedVerdict( + { + spans: [ + span("ld_signatures.verify", { + "ld_signatures.key_id": ldKeyId.href, + }), + ], + verifications: [measured("linked_data", "verified")], + }, + none, + ), + { + mechanism: "ld_signature", + result: "verified", + keyIri: ldKeyId.href, + keyFetches: [], + detail: null, + }, + ); + // A proof that failed before HTTP signatures were found missing. + assert.deepEqual( + reportedVerdict( + { + spans: [ + span( + "object_integrity_proofs.verify", + { "object_integrity_proofs.key_id": proofKey }, + { failed: true }, + ), + noSignature, + ], + verifications: [ + measured("object_integrity", "rejected"), + measured("http", "missing"), + ], + }, + none, + ), + { + mechanism: "object_integrity_proof", + result: "invalid_signature", + keyIri: proofKey, + keyFetches: [], + detail: null, + }, + ); + // Answered before verifying, a signature or proof carried was not tried. + assert.deepEqual( + reportedVerdict( + { spans: [], verifications: [] }, + { ldKeyIri: ldKeyId.href, proofMethods: [proofKey] }, + ), + { mechanism: null, result: "unattempted" }, + ); + // A proof Fedify gave up on without a report counts as tried once it went + // on to HTTP signatures. + assert.deepEqual( + reportedVerdict( + { spans: [noSignature], verifications: [measured("http", "missing")] }, + { ldKeyIri: null, proofMethods: [null, proofKey] }, + ), + { + mechanism: "object_integrity_proof", + result: "invalid_signature", + keyIri: proofKey, + keyFetches: [], + detail: null, + }, + ); + assert.deepEqual( + reportedVerdict( + { + spans: [ + span("http_signatures.verify", { + "http_signatures.verified": false, + "http_signatures.failure_reason": "keyFetchError", + "http_signatures.key_id": httpKeyId.href, + "http_signatures.key_fetch_status": 410, + }), + ], + verifications: [measured("http", "rejected")], + }, + none, + ), + { + mechanism: "http_signature", + result: "key_fetch_error", + keyIri: httpKeyId.href, + keyFetches: [], + detail: "keyFetchError: 410", + }, + ); + // No usable key came back, so nothing was checked: Fedify counted why. + const fetched = ( + result: string, + lookup: string, + statusCode?: number, + ): ObservedKeyFetch => ({ + result, + lookup: { result: lookup, statusCode: statusCode ?? null }, + keys: new Map(), + }); + const ldSpan = span("ld_signatures.verify", { + "ld_signatures.key_id": ldKeyId.href, + }); + for (const [keyFetches, result, detail] of [ + [[fetched("error", "network_error")], "key_fetch_error", "network_error"], + [[fetched("error", "error", 503)], "key_fetch_error", "503"], + [[fetched("error", "not_found", 404)], "key_fetch_error", "404"], + [[fetched("error", "invalid")], "key_fetch_error", "invalid"], + // The record of an earlier failure, found in the cache. + [[fetched("error", "hit")], "key_fetch_error", "cached"], + [ + [{ result: "error", lookup: null, keys: new Map() }], + "key_fetch_error", + "error", + ], + // The cached key did not verify, and fetching it again failed. + [ + [fetched("hit", "hit"), fetched("error", "network_error")], + "key_fetch_error", + "network_error", + ], + // The cached key did not verify, nor did the one fetched again. + [ + [fetched("hit", "hit"), fetched("fetched", "fetched")], + "invalid_signature", + null, + ], + [[], "invalid_signature", null], + ] as const) { + assert.deepEqual( + reportedVerdict( + { + spans: [ldSpan, noSignature], + verifications: [ + measured("linked_data", "rejected", keyFetches), + measured("http", "missing"), + ], + }, + none, + ), + { + mechanism: "ld_signature", + result, + keyIri: ldKeyId.href, + keyFetches, + detail: detail == null ? null : `keyFetchError: ${detail}`, + }, + ); + } + // Fedify stops at the first proof that fails: the fetch of the last counts. + assert.deepEqual( + reportedVerdict( + { + spans: [noSignature], + verifications: [ + measured("object_integrity", "verified", [ + fetched("fetched", "fetched"), + ]), + measured("object_integrity", "rejected", [ + fetched("error", "error", 500), + ]), + measured("http", "missing"), + ], + }, + { ldKeyIri: null, proofMethods: [proofKey] }, + ), + { + mechanism: "object_integrity_proof", + result: "key_fetch_error", + keyIri: proofKey, + keyFetches: [fetched("error", "error", 500)], + detail: "keyFetchError: 500", + }, + ); + // A proof that verified without authenticating the activity is no proof that + // failed, whatever Fedify found of the HTTP signature it went on to. + const verifiedProof = span("object_integrity_proofs.verify", { + "object_integrity_proofs.key_id": proofKey, + }); + const proofMeasured = measured("object_integrity", "verified", [ + fetched("hit", "hit"), + ]); + const proven = { ldKeyIri: null, proofMethods: [proofKey] }; + for (const [http, httpMeasured] of [ + [noSignature, measured("http", "missing")], + [ + span("http_signatures.verify", { + "http_signatures.verified": false, + "http_signatures.failure_reason": "invalidSignature", + "http_signatures.key_id": httpKeyId.href, + }), + measured("http", "rejected", [fetched("error", "invalid")]), + ], + ] as const) { + const { detail, ...verdict } = reportedVerdict( + { + spans: [verifiedProof, http], + verifications: [proofMeasured, httpMeasured], + }, + proven, + ); + assert.deepEqual(verdict, { + mechanism: "object_integrity_proof", + result: "verified", + keyIri: proofKey, + keyFetches: [fetched("hit", "hit")], + }); + assert.match(detail ?? "", /did not accept them as authenticating/u); + } + // An HTTP signature that then verified is the verdict, with its own fetches. + assert.deepEqual( + reportedVerdict( + { + spans: [ + verifiedProof, + span("http_signatures.verify", { + "http_signatures.verified": true, + "http_signatures.key_id": httpKeyId.href, + }), + ], + verifications: [ + proofMeasured, + measured("http", "verified", [fetched("fetched", "fetched")]), + ], + }, + proven, + ), + { + mechanism: "http_signature", + result: "verified", + keyIri: httpKeyId.href, + keyFetches: [fetched("fetched", "fetched")], + }, + ); + // Fedify calls a key document without a key an invalid signature. + assert.deepEqual( + reportedVerdict( + { + spans: [ + span("http_signatures.verify", { + "http_signatures.verified": false, + "http_signatures.failure_reason": "invalidSignature", + "http_signatures.key_id": httpKeyId.href, + }), + ], + verifications: [ + measured("http", "rejected", [fetched("error", "invalid")]), + ], + }, + none, + ), + { + mechanism: "http_signature", + result: "key_fetch_error", + keyIri: httpKeyId.href, + keyFetches: [fetched("error", "invalid")], + detail: "keyFetchError: invalid", + }, + ); + // HTTP signature verification that threw. + assert.deepEqual( + reportedVerdict( + { + spans: [ + span( + "http_signatures.verify", + { "http_signatures.key_id": httpKeyId.href }, + { failed: true }, + ), + ], + verifications: [measured("http", "error")], + }, + none, + ), + { + mechanism: "http_signature", + result: "invalid_signature", + keyIri: httpKeyId.href, + keyFetches: [], + detail: "Fedify could not verify the HTTP signature.", + }, + ); + // Fedify accepts an activity naming no actor, having nothing to verify. + assert.deepEqual( + reportedVerdict( + { + spans: [ + span( + "activitypub.inbox", + {}, + { + events: [ + { + name: "activitypub.activity.received", + attributes: { + "activitypub.activity.verified": true, + "ld_signatures.verified": false, + "http_signatures.verified": false, + }, + }, + ], + }, + ), + ], + verifications: [], + }, + none, + ), + { mechanism: null, result: "no_signature" }, + ); +}); + +it("stores the canonical inbox IRI apart from the URL a request arrived at", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { send } = await createRecorder(db); + const unknown = "00000000-0000-4000-8000-000000000299"; + const arrivals = [ + `http://test-instance.drfed.org./users/${localActorId}/inbox`, + "http://test-instance.drfed.org./inbox", + `http://test-instance.drfed.org.:443/users/${unknown}/inbox`, + ]; + for (const url of arrivals) { + await send(post(JSON.stringify(activity("canonical")), url)); + } + assert.deepEqual( + (await logs(db)).map((log) => [log.inboxUrl, log.requestUrl]), + [ + [inbox, arrivals[0]], + [sharedInbox, arrivals[1]], + [`${origin}/users/${unknown}/inbox`, arrivals[2]], + ], + ); + }); + assert.equal( + instanceUrl( + new URL("http://drfed.localhost:8888"), + "a.drfed.localhost:8888", + "/inbox", + ).href, + "http://a.drfed.localhost:8888/inbox", + ); +}); + +it("keeps IRIs that are not URLs out of URL fields, but in the request", async () => { + await withTestHarness(async ({ db, post: query }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const { send } = await createRecorder(db); + const body = JSON.stringify({ + type: "Create", + id: "not a url", + actor: "not an actor url", + }); + const signature = + 'keyId="not a key",headers="(request-target)",signature="AAAA"'; + await send(post(body, inbox, { signature })); + const result = await ( + await query( + { + query: `{ node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node { + activityIri remoteActorIri signedKeyIri remoteHost payload rawBody requestHeaders + } } } } } }`, + }, + auth, + ) + ).json(); + assert.equal(result.errors, undefined, JSON.stringify(result.errors)); + const { node } = result.data.node.activityLogs.edges[0]; + assert.deepEqual( + [node.activityIri, node.remoteActorIri, node.signedKeyIri], + [null, null, null], + ); + assert.equal(node.remoteHost, null); + assert.equal(node.payload.id, "not a url"); + assert.equal(node.payload.actor, "not an actor url"); + assert.equal(node.rawBody, body); + assert.equal( + new Map(node.requestHeaders).get("signature"), + signature, + ); + }); +}); + +it("orders logs by arrival, even when handling ends out of order", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const kv = new MemoryKvStore(); + const { contextLoader } = ( + await createFederation(db, { kv }) + ).createContext(new URL(inbox), undefined); + const federation = await createFederation(db, { + kv, + contextLoaderFactory: () => contextLoader, + documentLoaderFactory: () => () => + Promise.reject(new TypeError("offline")), + }); + const { promise: reached, resolve: enter } = Promise.withResolvers(); + const { promise: gate, resolve: release } = Promise.withResolvers(); + const recorder = createInboundRecorder({ + db, + rootOrigin, + federation: new Proxy(federation, { + get(target, property) { + if (property === "fetch") { + return async ( + request: Request, + options: FederationFetchOptions, + ) => { + if (request.headers.has("x-slow")) { + enter(); + await gate; + } + return await target.fetch(request, options); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }), + }); + const [slow, fast] = [activity("slow"), activity("fast")]; + const slowResponse = recorder.fetch( + post(JSON.stringify(slow), inbox, { "x-slow": "1" }), + fetchOptions, + ); + await reached; + await recorder.fetch(post(JSON.stringify(fast)), fetchOptions); + release(); + await slowResponse; + const iris = async (orderBy: { created: "asc" } | { id: "asc" }) => + (await db.query.activityLogs.findMany({ orderBy })).map( + (log) => log.activityIri, + ); + // Both the time and the ID of a log are chosen as its request arrives. + assert.deepEqual(await iris({ created: "asc" }), [slow.id, fast.id]); + assert.deepEqual(await iris({ id: "asc" }), [slow.id, fast.id]); + const [first, second] = await db.query.activityLogs.findMany({ + orderBy: { created: "asc" }, + }); + assert.ok(Temporal.Instant.compare(first!.completed!, first!.created) >= 0); + assert.ok( + Temporal.Instant.compare(first!.completed!, second!.completed!) > 0, + ); + }); +}); + +it("relates a log to every local actor it concerns, once", async () => { + await withTestHarness(async ({ db, post: query }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + await seedRemoteActor(db); + const { send } = await createRecorder(db); + const followers = "https://remote.example.com/users/bob/followers"; + const following = "https://remote.example.com/users/bob/following"; + await addActorCollectionItem(db, remoteActorId, "followers", localActorId); + const deliveries: [string, string, Record][] = [ + ["own", inbox, { to: localActorIri }], + ["shared", sharedInbox, { to: localActorIri, cc: [localActorIri] }], + ["object", sharedInbox, { to: { id: localActorIri, type: "Person" } }], + ["array", sharedInbox, { bcc: ["urn:other", localActorIri] }], + ["members", sharedInbox, { cc: followers }], + ["both", sharedInbox, { to: [localActorIri], cc: [followers] }], + ["empty", sharedInbox, { cc: following }], + ["public", sharedInbox, { to: "as:Public" }], + [ + "typed", + sharedInbox, + { type: ["Create", "https://example.com/ns#Custom"] }, + ], + ]; + for (const [id, url, extra] of deliveries) { + await send(post(JSON.stringify(activity(id, extra)), url)); + } + const rows = await logs(db); + assert.deepEqual( + rows.map((log) => [ + log.activityIri?.split("/").at(-1), + log.actorId, + log.actorLinks.map((link) => [ + link.actorId, + link.inboxOwner, + link.addressed, + link.viaCollectionIri, + ]), + ]), + [ + ["own", localActorId, [[localActorId, true, true, null]]], + ["shared", null, [[localActorId, false, true, null]]], + ["object", null, [[localActorId, false, true, null]]], + ["array", null, [[localActorId, false, true, null]]], + ["members", null, [[localActorId, false, true, followers]]], + ["both", null, [[localActorId, false, true, null]]], + ["empty", null, []], + ["public", null, []], + ["typed", null, []], + ], + ); + assert.deepEqual(rows.at(-1)?.types, [ + "Create", + "https://example.com/ns#Custom", + ]); + assert.equal(rows.at(-1)?.type, "Create"); + const read = async () => { + const result = await ( + await query( + { + query: `{ + actor: node(id: "${globalId("Actor", localActorId)}") { ... on Actor { activityLogs(first: 20) { edges { node { activityIri } } } } } + instance: node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 20) { edges { node { activityIri actor { uuid } } } } } } + }`, + }, + auth, + ) + ).json(); + assert.equal(result.errors, undefined, JSON.stringify(result.errors)); + return result.data; + }; + const names = (connection: { + edges: { node: { activityIri: string } }[]; + }) => + connection.edges.map((edge) => edge.node.activityIri.split("/").at(-1)); + const before = await read(); + assert.deepEqual(names(before.actor.activityLogs), [ + "both", + "members", + "array", + "object", + "shared", + "own", + ]); + assert.equal(before.instance.activityLogs.edges.length, deliveries.length); + assert.equal( + before.instance.activityLogs.edges.at(-1).node.actor.uuid, + localActorId, + ); + await db + .update(schema.actors) + .set({ deleted: Temporal.Now.instant() }) + .where(eq(schema.actors.id, localActorId)); + const after = await read(); + assert.equal(after.actor, null); + assert.equal(after.instance.activityLogs.edges.length, deliveries.length); + assert.equal(after.instance.activityLogs.edges.at(-1).node.actor, null); + }); +}); + +it("exposes what was observed, and pages through the versions of a key", async () => { + await withTestHarness(async ({ db, post: query }) => { + const auth = await seedAuthenticatedLocalInstance(db); + await seedLocalActor(db); + const pairs = [ + await generateCryptoKeyPair(), + await generateCryptoKeyPair(), + await generateCryptoKeyPair(), + ]; + const keys = new Map(); + const { send } = await createRecorder(db, keys); + const kvPrefix = httpKeyId.href; + const body = JSON.stringify(activity("fields")); + for (const pair of pairs) { + keys.set( + kvPrefix, + new CryptographicKey({ + id: httpKeyId, + owner: actorIri, + publicKey: pair.publicKey, + }), + ); + const signed = await signRequest(post(body), pair.privateKey, httpKeyId); + assert.equal((await send(signed)).status, 202); + } + const fields = `uuid status verificationMechanism verificationResult types + rawBody rawBodyBase64 requestHeaders requestUrl inboxUrl responseBody + recipientIris error signedKeyIri`; + const result = await ( + await query( + { + query: `query($after: String) { node(id: "${globalId("Instance", localInstanceId)}") { ... on Instance { activityLogs(first: 1) { edges { node { + ${fields} + verificationKey { key { versions(first: 2, after: $after) { edges { node { uuid fingerprint } } pageInfo { hasNextPage endCursor } } } } + } } } } } }`, + }, + auth, + ) + ).json(); + assert.equal(result.errors, undefined, JSON.stringify(result.errors)); + const { node } = result.data.node.activityLogs.edges[0]; + assert.equal(node.status, "acknowledged"); + assert.equal(node.verificationMechanism, "http_signature"); + assert.equal(node.verificationResult, "verified"); + assert.deepEqual(node.types, ["Create"]); + assert.equal(node.rawBody, body); + assert.equal(atob(node.rawBodyBase64), body); + assert.ok(new Map(node.requestHeaders).has("signature")); + assert.equal(node.requestUrl, inbox); + assert.equal(node.inboxUrl, inbox); + assert.deepEqual(node.recipientIris, []); + assert.equal(node.signedKeyIri, httpKeyId.href); + const stored = await db.query.keyVersions.findMany({ + orderBy: { firstSeen: "asc", id: "asc" }, + }); + assert.equal(stored.length, 3); + const firstPage = node.verificationKey.key.versions; + assert.deepEqual( + firstPage.edges.map((edge: { node: { uuid: string } }) => edge.node.uuid), + stored.slice(0, 2).map((version) => version.id), + ); + assert.equal(firstPage.pageInfo.hasNextPage, true); + const next = await ( + await query( + { + query: `query($after: String) { node(id: "${Buffer.from(`Key:${stored[0]!.keyId}`).toString("base64")}") { ... on Key { versions(first: 2, after: $after) { edges { node { uuid } } pageInfo { hasNextPage } } } } }`, + variables: { after: firstPage.pageInfo.endCursor }, + }, + auth, + ) + ).json(); + assert.equal(next.errors, undefined, JSON.stringify(next.errors)); + assert.deepEqual(next.data.node.versions, { + edges: [{ node: { uuid: stored[2]!.id } }], + pageInfo: { hasNextPage: false }, + }); + const anonymous = await ( + await query({ + query: `{ node(id: "${Buffer.from(`ActivityLog:${node.uuid}`).toString("base64")}") { ... on ActivityLog { ${fields} } } }`, + }) + ).json(); + assert.ok(anonymous.errors?.length); + assert.equal(anonymous.data?.node ?? null, null); + }); +}); diff --git a/packages/graphql/src/activity-log/inbound.ts b/packages/graphql/src/activity-log/inbound.ts new file mode 100644 index 0000000..d7e6660 --- /dev/null +++ b/packages/graphql/src/activity-log/inbound.ts @@ -0,0 +1,300 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Database } from "@drfed/models"; +import { recordInbound } from "@drfed/models/activity-log"; +import type { + ActivityLogVerificationResult, + Instance, +} from "@drfed/models/schema"; +import { type Uuid, uuidV7, validateUuid } from "@drfed/models/uuid"; +import type { FederationFetchOptions } from "@fedify/fedify"; +import type { DocumentLoader } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; + +import { canonicalizeAuthority, instanceUrl } from "../origin.ts"; +import { addressedIris, findAddressedActors } from "./addressing.ts"; +import { describeParsed, parseActivity, remoteHost } from "./describe.ts"; +import { describeError, receivedMeanwhile } from "./queue.ts"; +import { + type Report, + type TrackedFederation, + kvOf, + trackSettled, + unwrap, +} from "./tracking.ts"; +import { observeVerification } from "./verification.ts"; + +const logger = getLogger(["drfed", "graphql", "activity-log"]); + +type InboundStatus = "received" | "acknowledged" | "unverified" | "rejected"; + +/** + * The actual federation response determines whether an activity was accepted. + * @param statusCode Null when handling threw instead of answering. + * @returns The inbound delivery status. + */ +export function classifyInbound({ + statusCode, + handled, + verificationResult, +}: { + readonly statusCode: number | null; + readonly handled: boolean; + readonly verificationResult: ActivityLogVerificationResult; +}): InboundStatus { + if (statusCode != null && statusCode >= 200 && statusCode < 300) { + return handled ? "received" : "acknowledged"; + } + return verificationResult === "verified" ? "rejected" : "unverified"; +} + +/** + * Drop `cookie`, and reduce non-`Signature` `authorization` to its scheme. + * @returns The headers as `[name, value]` pairs. + */ +export function recordedHeaders(headers: Headers): [string, string][] { + return [...headers] + .filter(([name]) => name !== "cookie") + .map(([name, value]): [string, string] => { + if (name !== "authorization") return [name, value]; + const [scheme = ""] = value.trim().split(/\s+/u); + return [name, scheme.toLowerCase() === "signature" ? value : scheme]; + }); +} + +/** + * Parse a body from its octets. + * @returns The parsed value, or undefined when the body is not JSON. + */ +export function parseBody(body: Uint8Array): unknown { + try { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + return value; + } catch { + return undefined; + } +} + +async function findRecordingInstance(db: Database, host: string) { + try { + return await db.query.instances.findFirst({ + where: { + host: canonicalizeAuthority(host), + localId: { isNotNull: true }, + }, + }); + } catch (error) { + logger.error("Could not resolve the inbox recording instance: {error}", { + error, + }); + return undefined; + } +} + +async function readBody(request: Request): Promise { + try { + return new Uint8Array(await request.clone().arrayBuffer()); + } catch (error) { + logger.error("Could not read the inbox request body: {error}", { error }); + return undefined; + } +} + +async function readResponseBody(response: Response): Promise { + try { + return await response.clone().text(); + } catch (error) { + logger.error("Could not read the inbox response body: {error}", { error }); + return null; + } +} + +/** + * Wrap inbox POSTs while preserving Fedify responses even when recording fails. + * A request Fedify throws on is recorded too, and the exception thrown again. + * The federation must come from `createFederation()`, which lets the recorder + * see how Fedify verified each request and with which public key. + * @returns A fetch handler that records inbox observations. + */ +export function createInboundRecorder({ + db, + federation, + rootOrigin, +}: { + readonly db: Database; + readonly federation: TrackedFederation; + readonly rootOrigin: URL; +}): { + fetch( + request: Request, + options: FederationFetchOptions, + ): Promise; +} { + async function record( + request: Request, + instance: Instance, + identifier: string | undefined, + observed: { + readonly id: Uuid; + readonly body: Uint8Array; + readonly payload: unknown; + readonly report: Pick; + readonly outcome: PromiseSettledResult; + readonly handled: boolean; + readonly loaders: Parameters[1] & { + readonly contextLoader: DocumentLoader; + }; + readonly created: Temporal.Instant; + readonly completed: Temporal.Instant; + }, + ): Promise { + const { + id, + body, + payload, + report, + outcome, + handled, + loaders, + created, + completed, + } = observed; + const response = outcome.status === "fulfilled" ? outcome.value : null; + const statusCode = response?.status ?? null; + const verification = await observeVerification( + db, + request.headers, + payload, + report, + loaders.contextLoader, + ); + const owner = + identifier != null && validateUuid(identifier) + ? await db.query.actors.findFirst({ + where: { + id: identifier, + instanceId: instance.id, + localId: { isNotNull: true }, + }, + }) + : null; + const activity = + payload === undefined ? null : await parseActivity(payload, loaders); + const description = await describeParsed(payload, activity, loaders); + const status = classifyInbound({ + statusCode, + handled, + verificationResult: verification.result, + }); + const responseBody = + response == null ? null : await readResponseBody(response); + await recordInbound(db, { + ...description, + id, + instanceId: instance.id, + actorId: owner?.id ?? null, + addressed: await findAddressedActors( + db, + instance.id, + addressedIris(activity), + ), + status, + verificationMechanism: verification.mechanism, + verificationResult: verification.result, + signedKeyIri: verification.signedKeyIri, + verificationKeyId: verification.keyId, + remoteHost: remoteHost( + description.remoteActorIri, + verification.signedKeyIri, + ), + inboxUrl: + owner?.inboxUrl ?? + instanceUrl(rootOrigin, instance.host, new URL(request.url).pathname) + .href, + requestUrl: request.url, + headers: recordedHeaders(request.headers), + body, + statusCode, + responseBody, + error: + outcome.status === "rejected" + ? describeError(outcome.reason) + : status === "unverified" || status === "rejected" + ? (verification.detail ?? (responseBody || null)) + : null, + payload, + created, + completed, + }); + // A queue worker may have run the inbox listener before this was recorded. + const kv = kvOf(federation); + if (status === "acknowledged" && kv != null) { + await receivedMeanwhile(db, kv, id); + } + } + + return { + async fetch(request, options) { + // Arrival, not insertion, orders the logs: requests may finish out of order. + const created = Temporal.Now.instant(); + if (request.method !== "POST") { + return await federation.fetch(request, options); + } + const ctx = federation.createContext(request, options.contextData); + const route = ctx.parseUri(new URL(request.url)); + if (route?.type !== "inbox") { + return await federation.fetch(request, options); + } + // Unclaimed subdomains must not create orphaned public-key history. + const instance = await findRecordingInstance(db, ctx.host); + const body = instance == null ? undefined : await readBody(request); + if (instance == null || body == null) { + return await federation.fetch(request, options); + } + const loaders = { + documentLoader: ctx.documentLoader, + contextLoader: ctx.contextLoader, + }; + const payload = parseBody(body); + // Fedify reports how it verified the request as it handles it; nothing + // is verified again, so the log shows Fedify's outcome and keys. + // Chosen now, so that a queued inbox message can name the log. + const id = uuidV7(); + const { outcome, handled, ...report } = await trackSettled( + () => federation.fetch(request, options), + { inboundLogId: id }, + ); + const completed = Temporal.Now.instant(); + try { + await record(request, instance, route.identifier, { + id, + body, + payload, + report, + outcome, + handled, + loaders, + created, + completed, + }); + } catch (error) { + logger.error("Could not record inbox activity: {error}", { error }); + } + return unwrap(outcome); + }, + }; +} diff --git a/packages/graphql/src/activity-log/keycache.ts b/packages/graphql/src/activity-log/keycache.ts new file mode 100644 index 0000000..69ac2c1 --- /dev/null +++ b/packages/graphql/src/activity-log/keycache.ts @@ -0,0 +1,150 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { + FetchKeyErrorResult, + KeyCache, + KvKey, + KvStore, +} from "@fedify/fedify"; +import { CryptographicKey, Multikey } from "@fedify/vocab"; + +import type { ObservedKeyFetch } from "./tracking.ts"; + +type Loaders = Parameters[1]; +type DiagnosticKeyCache = KeyCache & { + getFetchError(keyId: URL): Promise; + setFetchError( + keyId: URL, + error: FetchKeyErrorResult | undefined, + ): Promise; +}; +// Fedify's KvKeyCache keeps each key below the prefix under this generation. +const generation = "2"; +const keyTtl = Temporal.Duration.from({ days: 30 }); +const unavailableTtl = Temporal.Duration.from({ minutes: 10 }); + +async function parseKey( + value: unknown, + options: Loaders, +): Promise { + try { + return await CryptographicKey.fromJsonLd(value, options); + } catch { + try { + return await Multikey.fromJsonLd(value, options); + } catch { + return undefined; + } + } +} + +/** + * KV wire format shared with the installed Fedify KvKeyCache. + * @returns A compatible public-key cache with fetch-error diagnostics. + */ +export function createKeyCache( + kv: KvStore, + prefix: KvKey = ["_fedify", "publicKey"], + options: Loaders = {}, +): DiagnosticKeyCache { + const entryKey = (id: URL): KvKey => [...prefix, generation, id.href]; + const errorKey = (id: URL): KvKey => [...prefix, "__fetchError", id.href]; + return { + async get(keyId) { + const value = await kv.get(entryKey(keyId)); + if (value == null) return value; + const key = await parseKey(value, options); + if (key == null) await kv.delete(entryKey(keyId)); + return key; + }, + async set(keyId, key) { + await kv.set( + entryKey(keyId), + key == null ? null : await key.toJsonLd(options), + { ttl: key == null ? unavailableTtl : keyTtl }, + ); + }, + async getFetchError(keyId) { + const cached = await kv.get>(errorKey(keyId)); + if (cached == null || typeof cached !== "object") return undefined; + if ( + typeof cached.status === "number" && + typeof cached.statusText === "string" && + Array.isArray(cached.headers) && + typeof cached.body === "string" + ) { + return { + status: cached.status, + response: new Response(cached.body, { + status: cached.status, + statusText: cached.statusText, + headers: cached.headers as [string, string][], + }), + }; + } + if ( + typeof cached.errorName === "string" && + typeof cached.errorMessage === "string" + ) { + const error = new Error(cached.errorMessage); + error.name = cached.errorName; + return { error }; + } + return undefined; + }, + async setFetchError(keyId, result) { + if (result == null) return await kv.delete(errorKey(keyId)); + const value = + "status" in result + ? { + status: result.status, + statusText: result.response.statusText, + headers: Array.from(result.response.headers.entries()), + body: await result.response.clone().text(), + } + : { + errorName: result.error.name, + errorMessage: result.error.message, + }; + await kv.set(errorKey(keyId), value, { ttl: unavailableTtl }); + }, + }; +} + +/** + * The key a verification used, out of the key fetches `trackRequest()` + * reported of it: the last its public-key cache entry held while a fetch + * brought a key, even one fetched again, and even when fetching it again then + * failed and emptied the entry. What a fetch read but could not use does not + * count, nor what another verification of the request found under the IRI. + * @param fetches The key fetches of the one verification, in order. + * @returns The key, or null when no fetch brought one. + */ +export async function trackedKey( + fetches: readonly ObservedKeyFetch[], + keyIri: string, + options: Loaders = {}, +): Promise { + const entry = JSON.stringify([generation, keyIri]); + const brought = await Promise.all( + fetches + .filter(({ result }) => result === "hit" || result === "fetched") + .flatMap(({ keys }) => keys.get(entry) ?? []) + .map((value) => (value == null ? undefined : parseKey(value, options))), + ); + return brought.findLast((key) => key != null) ?? null; +} diff --git a/packages/graphql/src/activity-log/outbound.test.ts b/packages/graphql/src/activity-log/outbound.test.ts new file mode 100644 index 0000000..481c842 --- /dev/null +++ b/packages/graphql/src/activity-log/outbound.test.ts @@ -0,0 +1,325 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable max-statements no-await-in-loop no-throw-literal +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { deliverActivity, groupRecipients } from "@drfed/graphql/activity-log"; +import createFederation from "@drfed/graphql/federation"; +import type { Database } from "@drfed/models"; +import { + type Context, + InProcessMessageQueue, + MemoryKvStore, + SendActivityError, + type SendActivityOptions, + generateCryptoKeyPair, +} from "@fedify/fedify"; +import { + type Activity, + Create, + PUBLIC_COLLECTION, + type Recipient, +} from "@fedify/vocab"; + +import { withTemporaryDatabase } from "../harness.test.ts"; +import { localActorId, seedLocalActor } from "../seed.test.ts"; +import { withInbox } from "./remote.test.ts"; + +const origin = "https://test-instance.drfed.org"; +const localActorIri = new URL(`${origin}/users/${localActorId}`); +const inboxId = new URL("https://remote.example/inbox"); +const alice = { id: new URL("https://remote.example/users/alice"), inboxId }; +const bob = { id: new URL("https://remote.example/users/bob"), inboxId }; +const sender = { identifier: localActorId }; +const create = ( + id: string, + extra: ConstructorParameters[0] = {}, +) => + new Create({ + id: new URL(`${origin}/activity/${id}`), + actor: localActorIri, + ...extra, + }); + +/** + * A context whose `sendActivity()` is `send`, for a delivery whose outcome the + * test decides. + * @returns The context. + */ +async function createContext( + db: Database, + send: (activity: Activity, recipients: readonly Recipient[]) => Promise, +): Promise> { + const federation = await createFederation(db, { kv: new MemoryKvStore() }); + return new Proxy(federation.createContext(new URL(origin), undefined), { + get(target, property) { + return property === "sendActivity" + ? (_sender: unknown, recipients: Recipient[], activity: Activity) => + send(activity, recipients) + : Reflect.get(target, property); + }, + }); +} + +/** + * A context delivering through Fedify with a key of the test's own, since + * local key pairs arrive with #87. With a queue, its worker is never started. + * @returns The context, and the activities passed to Fedify. + */ +async function createDeliveringContext( + db: Database, + { + queue, + ...options + }: SendActivityOptions & { readonly queue?: InProcessMessageQueue } = {}, +): Promise<{ readonly ctx: Context; readonly passed: Activity[] }> { + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + allowPrivateAddress: true, + ...(queue == null ? {} : { queue, manuallyStartQueue: true }), + }); + const { privateKey } = await generateCryptoKeyPair("Ed25519"); + const key = { keyId: new URL(`${localActorIri.href}#key`), privateKey }; + const passed: Activity[] = []; + const ctx = new Proxy(federation.createContext(new URL(origin), undefined), { + get(target, property) { + return property === "sendActivity" + ? (_sender: unknown, recipients: Recipient[], activity: Activity) => { + passed.push(activity); + return target.sendActivity(key, recipients, activity, options); + } + : Reflect.get(target, property); + }, + }); + return { ctx, passed }; +} + +const logs = (db: Database) => + db.query.activityLogs.findMany({ orderBy: { id: "asc" } }); + +it("keeps the outcome of an earlier attempt when a retry to the same inbox fails", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const activity = create("retried"); + for (const [statusCode, message, body] of [ + [503, "unavailable", "First"], + [502, "bad gateway", "Second"], + ] as const) { + const ctx = await createContext(db, () => + Promise.reject( + new SendActivityError(inboxId, statusCode, message, body), + ), + ); + await assert.rejects( + deliverActivity(db, ctx, sender, alice, activity), + SendActivityError, + ); + } + assert.deepEqual( + (await logs(db)).map((log) => [ + log.status, + log.statusCode, + log.error, + log.responseBody, + ]), + [ + ["failed", 503, "unavailable", "First"], + ["failed", 502, "bad gateway", "Second"], + ], + ); + }); +}); + +it("settles a delivery whose remote response holds text PostgreSQL cannot store", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const body = "error\u0000details"; + const ctx = await createContext(db, () => + Promise.reject( + new SendActivityError(inboxId, 500, `Failed:\n${body}`, body), + ), + ); + await assert.rejects( + deliverActivity(db, ctx, sender, alice, create("nul")), + SendActivityError, + ); + const [log] = await db.query.activityLogs.findMany({ + with: { attempts: true }, + }); + assert.deepEqual( + [log?.status, log?.statusCode, log?.error, log?.responseBody], + ["failed", 500, "Failed:\nerror\ufffddetails", "error\ufffddetails"], + ); + assert.deepEqual( + log?.attempts.map((attempt) => [ + attempt.succeeded, + attempt.statusCode, + attempt.responseBody, + ]), + [[false, 500, "error\ufffddetails"]], + ); + }); +}); + +it("logs every recipient of a shared inbox and strips blind recipients before delivery", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { ctx, passed } = await createDeliveringContext(db); + const activity = create("blind", { + tos: [alice.id], + btos: [new URL("urn:bto")], + bccs: [bob.id], + }); + const [log, ...rest] = await withInbox( + [[202, ""]], + async (inbox, received) => { + const shared = { id: alice.id, inboxId: inbox }; + const other = { id: bob.id, inboxId: inbox }; + await deliverActivity( + db, + ctx, + sender, + [shared, other, shared], + activity, + ); + assert.equal(received.length, 1); + const sent = JSON.parse(received[0]?.body ?? "") as Record< + string, + unknown + >; + assert.equal(sent.to, alice.id.href); + assert.equal("bto" in sent, false); + assert.equal("bcc" in sent, false); + return await logs(db); + }, + ); + assert.deepEqual(rest, []); + assert.equal(log?.status, "sent"); + assert.equal(log?.statusCode, 202); + assert.deepEqual(log?.recipientIris, [alice.id.href, bob.id.href]); + assert.equal(log?.remoteActorIri, null); + assert.match(log?.remoteHost ?? "", /^127\.0\.0\.1:\d+$/u); + const payload = log?.payload as Record; + assert.equal(payload.to, alice.id.href); + assert.equal("bto" in payload, false); + assert.equal("bcc" in payload, false); + assert.equal(passed.length, 1); + assert.deepEqual(passed[0]?.btoIds, []); + assert.deepEqual(passed[0]?.bccIds, []); + assert.deepEqual(passed[0]?.toIds, [alice.id]); + assert.deepEqual(activity.bccIds, [bob.id]); + assert.deepEqual(await db.query.activityLogActors.findMany(), [ + { + logId: log?.id, + actorId: localActorId, + inboxOwner: false, + addressed: false, + sender: true, + viaCollectionIri: null, + }, + ]); + }); + assert.deepEqual( + [ + ...groupRecipients( + [ + alice, + { id: localActorIri, inboxId: new URL(`${origin}/inbox`) }, + { id: PUBLIC_COLLECTION, inboxId }, + { id: bob.id, inboxId: null }, + { id: null, inboxId }, + ], + localActorIri.href, + ), + ], + [[inboxId.href, [alice]]], + ); +}); + +it("logs no delivery to a recipient Fedify leaves out for having no ID", async () => { + for (const queue of [undefined, new InProcessMessageQueue()]) { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { ctx } = await createDeliveringContext(db, { + ...(queue == null ? {} : { queue }), + }); + await withInbox([[202, ""]], async (inbox, received) => { + const anonymous = { id: null, inboxId: inbox }; + const named = { id: alice.id, inboxId: inbox }; + await deliverActivity(db, ctx, sender, anonymous, create("anonymous")); + assert.deepEqual(received, []); + assert.deepEqual(await logs(db), []); + // Sharing an inbox with a recipient Fedify delivers to changes nothing. + await deliverActivity( + db, + ctx, + sender, + [anonymous, named], + create("mixed"), + ); + const [log, ...rest] = await logs(db); + assert.deepEqual(rest, []); + assert.deepEqual(log?.recipientIris, [alice.id.href]); + assert.equal(log?.remoteActorIri, alice.id.href); + assert.equal(log?.status, queue == null ? "sent" : "queued"); + assert.equal(received.length, queue == null ? 1 : 0); + }); + }); + } +}); + +it("settles a delivery Fedify returns from without making", async () => { + // Fedify leaves out every recipient at an excluded origin, and returns as if + // it had delivered. + for (const queue of [undefined, new InProcessMessageQueue()]) { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + await withInbox([[202, ""]], async (inbox, received) => { + const { ctx } = await createDeliveringContext(db, { + ...(queue == null ? {} : { queue }), + excludeBaseUris: [inbox], + }); + const recipient = { id: alice.id, inboxId: inbox }; + await deliverActivity(db, ctx, sender, recipient, create("excluded")); + assert.deepEqual(received, []); + const [log] = await db.query.activityLogs.findMany({ + with: { attempts: true }, + }); + assert.equal(log?.status, "permanently_failed"); + assert.equal(log?.statusCode, null); + assert.equal(log?.error, "Fedify made no delivery to the inbox."); + assert.ok(log?.completed != null); + assert.deepEqual(log?.attempts, []); + }); + }); + } +}); + +it("leaves a delivery queued until the outbox worker attempts it", async () => { + await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + const { ctx } = await createDeliveringContext(db, { + queue: new InProcessMessageQueue(), + }); + await deliverActivity(db, ctx, sender, alice, create("queued")); + const [log] = await logs(db); + assert.equal(log?.status, "queued"); + assert.equal(log?.completed, null); + assert.equal(log?.remoteActorIri, alice.id.href); + }); +}); diff --git a/packages/graphql/src/activity-log/outbound.ts b/packages/graphql/src/activity-log/outbound.ts new file mode 100644 index 0000000..6c62419 --- /dev/null +++ b/packages/graphql/src/activity-log/outbound.ts @@ -0,0 +1,210 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Database } from "@drfed/models"; +import { recordOutbound, settleOutbound } from "@drfed/models/activity-log"; +import type { ActivityLog } from "@drfed/models/schema"; +import type { Uuid } from "@drfed/models/uuid"; +import type { Context, Federation } from "@fedify/fedify"; +import { + type Activity, + PUBLIC_COLLECTION, + type Recipient, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; + +import { canonicalizeAuthority } from "../origin.ts"; +import { describeActivity, remoteHost } from "./describe.ts"; +import { + type Delivery, + type Settlement, + deliveredStatus, + failureOf, + reportsSent, + withDelivery, +} from "./queue.ts"; +import { trackRequest } from "./tracking.ts"; + +const logger = getLogger(["drfed", "graphql", "activity-log"]); + +const queued = new WeakSet>(); + +/** + * How a delivery Fedify returned from without sending or enqueuing settles: + * no attempt was made, and none will be. + */ +const undelivered: Settlement = { + status: "permanently_failed", + attempted: false, + statusCode: null, + error: "Fedify made no delivery to the inbox.", + responseBody: null, +}; + +/** Mark a federation as delivering through a message queue. */ +export function markQueued(federation: Federation): void { + queued.add(federation); +} + +/** + * Group recipients by inbox, without the sender and Public. Fedify delivers + * only to a recipient that has both an ID and an inbox, so any other is left + * out. + * @returns The recipients of each inbox URL. + */ +export function groupRecipients( + recipients: readonly Recipient[], + senderIri: string | undefined, +): Map { + const targets = new Map(); + for (const recipient of recipients) { + const iri = recipient.id?.href; + if ( + iri == null || + recipient.inboxId == null || + iri === senderIri || + iri === PUBLIC_COLLECTION.href + ) { + continue; + } + const url = recipient.inboxId.href; + const group = targets.get(url) ?? []; + if (group.some((member) => member.id?.href === iri)) continue; + targets.set(url, [...group, recipient]); + } + return targets; +} + +/** + * Deliver to explicit recipients through the current federation. + * Each activity needs a unique IRI. `bto` and `bcc` are removed before delivery. + * A recipient without an ID or an inbox gets no delivery, and so no log. + * With a message queue, `createFederation()` settles each attempt the worker + * makes; without one, the one attempt settles here. + * Local actor key dispatchers must be registered before using this entry point. + */ +export async function deliverActivity( + db: Database, + ctx: Context, + sender: { identifier: Uuid }, + recipients: Recipient | Recipient[], + activity: Activity, +): Promise { + if (activity.id == null) { + throw new TypeError("A delivered activity must have an ID."); + } + const actor = await db.query.actors.findFirst({ + where: { + id: sender.identifier, + localId: { isNotNull: true }, + instance: { + host: canonicalizeAuthority(ctx.host), + localId: { isNotNull: true }, + }, + }, + }); + if (actor == null) { + throw new TypeError("Delivery requires a local sender on this instance."); + } + const activityIri = activity.id.href; + const delivered = activity.clone({ btos: [], bccs: [] }); + const synchronous = !queued.has(ctx.federation); + const targets = groupRecipients( + Array.isArray(recipients) ? recipients : [recipients], + activity.actorId?.href, + ); + const results = await Promise.allSettled( + Array.from(targets, async ([inboxUrl, group]) => { + let row: ActivityLog | undefined; + try { + const payload = await delivered.toJsonLd({ + format: "compact", + contextLoader: ctx.contextLoader, + }); + const description = await describeActivity(payload, { + contextLoader: ctx.contextLoader, + }); + const recipientIris = group.flatMap((recipient) => + recipient.id == null ? [] : [recipient.id.href], + ); + const remoteActorIri = + group.length === 1 ? (recipientIris[0] ?? null) : null; + row = await recordOutbound(db, { + ...description, + instanceId: actor.instanceId, + actorId: actor.id, + activityIri, + remoteActorIri, + remoteHost: remoteHost(remoteActorIri, inboxUrl), + inboxUrl, + recipientIris, + payload, + }); + } catch (error) { + logger.error("Could not record outgoing activity: {error}", { error }); + } + const settle = async (settlement: Settlement) => { + if (row == null) return; + try { + await settleOutbound(db, { + ...settlement, + id: row.id, + activityIri, + inboxUrl, + }); + } catch (error) { + logger.error("Could not settle outgoing activity: {error}", { + error, + }); + } + }; + // Resolve each destination independently: one failing inbox must not mark + // a successful delivery as failed or leave it queued. + const send = () => ctx.sendActivity(sender, group, delivered); + const delivery: Delivery | undefined = + row == null ? undefined : { logId: row.id, inboxUrl, enqueued: false }; + const { spans, responses } = await trackRequest(() => + delivery == null ? send() : withDelivery(delivery, send), + ).catch(async (error: unknown) => { + await settle({ + ...failureOf(error), + status: "failed", + attempted: true, + }); + throw error; + }); + // Fedify returns without sending to a recipient it leaves out: the sent + // event tells a delivery made from none, and the enqueued message a + // delivery pending from none. A group is always one inbox, so Fedify + // never fans it out through a queue. + if (synchronous) { + await settle( + reportsSent(spans, inboxUrl) + ? { + status: "sent", + attempted: true, + statusCode: deliveredStatus(responses, inboxUrl), + } + : undelivered, + ); + } else if (delivery?.enqueued === false) { + await settle(undelivered); + } + }), + ); + const failure = results.find((result) => result.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; +} diff --git a/packages/graphql/src/activity-log/queue.test.ts b/packages/graphql/src/activity-log/queue.test.ts new file mode 100644 index 0000000..ecb3a54 --- /dev/null +++ b/packages/graphql/src/activity-log/queue.test.ts @@ -0,0 +1,377 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// These tests run Fedify's own delivery and outbox worker against an inbox +// served on a local port. +// oxlint-disable max-statements no-await-in-loop +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { + deliverActivity, + queuedSettlements, +} from "@drfed/graphql/activity-log"; +import createFederation from "@drfed/graphql/federation"; +import type { Database } from "@drfed/models"; +import { + type Context, + InProcessMessageQueue, + MemoryKvStore, + type MessageQueue, + generateCryptoKeyPair, +} from "@fedify/fedify"; +import { type Activity, Create, type Recipient } from "@fedify/vocab"; + +import { withTemporaryDatabase } from "../harness.test.ts"; +import { localActorId, seedLocalActor } from "../seed.test.ts"; +import { withInbox } from "./remote.test.ts"; + +const origin = "https://test-instance.drfed.org"; +const localActorIri = new URL(`${origin}/users/${localActorId}`); +const alice = new URL("https://remote.example/users/alice"); + +/** + * Run with a temporary database holding the local actor deliveries are from. + * @returns The result of the run. + */ +async function withSeededDatabase( + run: (db: Database) => Promise, +): Promise { + return await withTemporaryDatabase(async (db) => { + await seedLocalActor(db); + return await run(db); + }); +} + +const logs = (db: Database, activityIri: string) => + db.query.activityLogs.findMany({ + where: { activityIri }, + orderBy: { created: "asc", id: "asc" }, + with: { attempts: { orderBy: { created: "asc", id: "asc" } } }, + }); +type Log = Awaited>[number]; + +const results = (log: Log | undefined) => + log?.attempts.map((attempt) => [ + attempt.succeeded, + attempt.statusCode, + attempt.responseBody, + ]); + +/** + * Deliver one activity through Fedify to `inbox`, with `queue` if any, until + * every log is settled for good. The database holds the local actor. + * @param activity What tells the activity from others delivered from the + * same database. + * @param retries How many times the worker may retry a failed attempt. + * @param deliveries How many times to deliver the activity before the worker + * starts. + * @param algorithm The sender's key. Fedify signs requests only with RSA. + * @returns The logs, oldest first. + */ +async function deliver( + db: Database, + inbox: URL, + { + activity: name = "1", + queue, + retries = 3, + deliveries = 1, + algorithm = "Ed25519", + }: { + readonly activity?: string; + readonly queue?: MessageQueue; + readonly retries?: number; + readonly deliveries?: number; + readonly algorithm?: "Ed25519" | "RSASSA-PKCS1-v1_5"; + } = {}, +): Promise { + const federation = await createFederation(db, { + kv: new MemoryKvStore(), + allowPrivateAddress: true, + ...(queue == null ? {} : { queue, manuallyStartQueue: true }), + circuitBreaker: false, + outboxRetryPolicy: ({ attempts }) => + attempts < retries ? Temporal.Duration.from({ milliseconds: 1 }) : null, + }); + // Local key pairs arrive with #87; sign with a key of the test's own. + const { privateKey } = await generateCryptoKeyPair(algorithm); + const key = { keyId: new URL(`${localActorIri.href}#key`), privateKey }; + const ctx: Context = new Proxy( + federation.createContext(new URL(origin), undefined), + { + get(target, property) { + return property === "sendActivity" + ? (_sender: unknown, recipients: Recipient[], activity: Activity) => + target.sendActivity(key, recipients, activity) + : Reflect.get(target, property); + }, + }, + ); + const activity = new Create({ + id: new URL(`${origin}/activity/${name}`), + actor: localActorIri, + }); + const recipient = { id: alice, inboxId: inbox }; + for (let count = 0; count < deliveries; count += 1) { + await deliverActivity( + db, + ctx, + { identifier: localActorId }, + recipient, + activity, + ) + // A synchronous failure is thrown as well as logged. + .catch(() => undefined); + } + const activityIri = activity.id?.href ?? ""; + if (queue == null) return await logs(db, activityIri); + const controller = new AbortController(); + const worker = federation.startQueue(undefined, { + signal: controller.signal, + }); + try { + for (let tries = 0; tries < 500; tries += 1) { + const found = await logs(db, activityIri); + const settled = found.every((log) => + ["sent", "permanently_failed", "abandoned"].includes(log.status), + ); + if (found.length === deliveries && settled) return found; + await sleep(10); + } + return assert.fail("The deliveries never settled."); + } finally { + controller.abort(); + await worker; + } +} + +it("keeps the status a remote inbox accepted a delivery with", async () => { + await withSeededDatabase(async (db) => { + const [log] = await withInbox([[202, ""]], (inbox) => deliver(db, inbox)); + assert.equal(log?.status, "sent"); + assert.equal(log?.statusCode, 202); + assert.deepEqual(results(log), [[true, 202, null]]); + }); +}); + +it("keeps the status a redirected delivery ended with", async () => { + // Fedify follows a redirect itself when it signs the request, and leaves it + // to `fetch()` otherwise, which follows a 303 with a GET. + await withSeededDatabase(async (db) => { + for (const algorithm of ["Ed25519", "RSASSA-PKCS1-v1_5"] as const) { + for (const redirect of [303, 307]) { + for (const queued of [false, true]) { + const [log] = await withInbox( + [ + [redirect, "", "/moved"], + [202, ""], + ], + (inbox) => + deliver(db, inbox, { + activity: `${algorithm}-${redirect}-${queued}`, + algorithm, + ...(queued ? { queue: new InProcessMessageQueue() } : {}), + }), + ); + assert.equal(log?.status, "sent"); + assert.equal(log?.statusCode, 202); + assert.deepEqual(results(log), [[true, 202, null]]); + } + } + } + }); +}); + +it("follows a redirect to a location outside ASCII as Fedify reads it", async () => { + // The header carries the UTF-8 bytes of the path, which Node.js writes from + // the Latin-1 string of them. Fedify follows the redirect itself whether it + // signs the request or not, reading them back as Latin-1. + const location = Buffer.from("/caf\u00e9", "utf8").toString("latin1"); + await withSeededDatabase(async (db) => { + for (const algorithm of ["Ed25519", "RSASSA-PKCS1-v1_5"] as const) { + const { log, paths } = await withInbox( + [ + [307, "", location], + [202, ""], + ], + async (inbox, received) => { + const [delivered] = await deliver(db, inbox, { + activity: algorithm, + algorithm, + }); + return { + log: delivered, + paths: received.map((request) => request.url), + }; + }, + ); + assert.equal(log?.status, "sent"); + assert.equal(log?.statusCode, 202); + assert.deepEqual(paths, ["/inbox", "/caf%C3%83%C2%A9"]); + } + }); +}); + +it("settles a queued delivery the remote inbox accepts", async () => { + await withSeededDatabase(async (db) => { + const [log] = await withInbox([[202, ""]], (inbox) => + deliver(db, inbox, { queue: new InProcessMessageQueue() }), + ); + assert.equal(log?.status, "sent"); + assert.deepEqual(results(log), [[true, 202, null]]); + assert.ok(log?.completed != null); + }); +}); + +it("keeps every failed attempt of a queued delivery that is retried into success", async () => { + await withSeededDatabase(async (db) => { + const [log] = await withInbox( + [ + [503, "busy"], + [202, ""], + ], + (inbox) => deliver(db, inbox, { queue: new InProcessMessageQueue() }), + ); + assert.deepEqual(results(log), [ + [false, 503, "busy"], + [true, 202, null], + ]); + assert.equal(log?.error, null); + }); +}); + +it("records network failures, and abandons a delivery once retries run out", async () => { + await withSeededDatabase(async (db) => { + const [log] = await withInbox(null, (inbox) => + deliver(db, inbox, { queue: new InProcessMessageQueue(), retries: 1 }), + ); + assert.deepEqual(results(log), [ + [false, null, null], + [false, null, null], + ]); + for (const attempt of log?.attempts ?? []) { + assert.match(attempt.error ?? "", /ECONNREFUSED/u); + } + }); +}); + +it("retries by Fedify's policy even with a queue that retries natively", async () => { + await withSeededDatabase(async (db) => { + const queue = new InProcessMessageQueue(); + Object.defineProperty(queue, "nativeRetrial", { value: true }); + const [log] = await withInbox( + [ + [503, "busy"], + [503, "busy"], + ], + (inbox) => deliver(db, inbox, { queue, retries: 1 }), + ); + assert.deepEqual(results(log), [ + [false, 503, "busy"], + [false, 503, "busy"], + ]); + }); +}); + +it("settles a permanent failure without retrying it", async () => { + await withSeededDatabase(async (db) => { + const [log] = await withInbox([[410, "gone"]], (inbox) => + deliver(db, inbox, { queue: new InProcessMessageQueue() }), + ); + assert.equal(log?.status, "permanently_failed"); + assert.deepEqual(results(log), [[false, 410, "gone"]]); + assert.equal(log?.statusCode, 410); + }); +}); + +it("settles each delivery of an activity sent twice to the same inbox", async () => { + await withSeededDatabase(async (db) => { + // The worker takes messages in order, so both deliveries are pending when + // their retries arrive: the first retry is refused for good. + const [first, second] = await withInbox( + [ + [503, "busy"], + [503, "busy"], + [410, "gone"], + [202, ""], + ], + (inbox) => + deliver(db, inbox, { + queue: new InProcessMessageQueue(), + retries: 1, + deliveries: 2, + }), + ); + assert.equal(first?.status, "permanently_failed"); + assert.deepEqual(results(first), [ + [false, 503, "busy"], + [false, 410, "gone"], + ]); + assert.equal(second?.status, "sent"); + assert.deepEqual(results(second), [ + [false, 503, "busy"], + [true, 202, null], + ]); + }); +}); + +it("translates what the worker reported into settlements", () => { + const attempt = { + activityIri: `${origin}/activity/1`, + inboxUrl: "https://remote.example/inbox", + }; + const failure = { statusCode: 503, error: "busy", responseBody: null }; + const expired = { + statusCode: null, + error: "Circuit breaker held activity expired.", + responseBody: null, + }; + assert.deepEqual( + queuedSettlements({ ...attempt, sent: true, statusCode: 202 }), + [{ status: "sent", attempted: true, statusCode: 202 }], + ); + // Held back by the circuit breaker before sending: nothing was attempted. + assert.deepEqual(queuedSettlements(attempt), []); + // Dropped by the circuit breaker before sending. + assert.deepEqual(queuedSettlements({ ...attempt, permanent: expired }), [ + { ...expired, status: "permanently_failed", attempted: false }, + ]); + // Failed and retried, or held back after failing. + for (const outcomes of [["retried"], []]) { + assert.deepEqual(queuedSettlements({ ...attempt, failure, outcomes }), [ + { ...failure, status: "failed", attempted: true }, + ]); + } + assert.deepEqual( + queuedSettlements({ ...attempt, failure, outcomes: ["abandoned"] }), + [{ ...failure, status: "abandoned", attempted: true }], + ); + // Failed, then dropped because the circuit breaker held it too long. + assert.deepEqual( + queuedSettlements({ + ...attempt, + failure, + permanent: expired, + outcomes: ["abandoned"], + }), + [ + { ...failure, status: "failed", attempted: true }, + { ...expired, status: "permanently_failed", attempted: false }, + ], + ); +}); diff --git a/packages/graphql/src/activity-log/queue.ts b/packages/graphql/src/activity-log/queue.ts new file mode 100644 index 0000000..9673740 --- /dev/null +++ b/packages/graphql/src/activity-log/queue.ts @@ -0,0 +1,509 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { Database } from "@drfed/models"; +import { + type OutboundSettlement, + receiveInbound, + settleOutbound, +} from "@drfed/models/activity-log"; +import { type Uuid, validateUuid } from "@drfed/models/uuid"; +import { + type FederationQueueOptions, + type KvKey, + type KvStore, + type MessageQueue, + type OutboxErrorHandler, + type OutboxPermanentFailureHandler, + SendActivityError, +} from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; + +import { + type ObservedResponse, + type ObservedSpan, + trackRequest, + tracking, + untracked, +} from "./tracking.ts"; + +const logger = getLogger(["drfed", "graphql", "activity-log"]); + +/** How a delivery settles, apart from which delivery it is. */ +export type Settlement = Omit< + OutboundSettlement, + "activityIri" | "inboxUrl" | "id" +>; + +/** What a failed attempt shows. */ +export interface Failure { + readonly statusCode: number | null; + readonly error: string; + readonly responseBody: string | null; +} + +const MAX_CAUSES = 3; + +/** + * Network failures such as `fetch failed` carry DNS, TCP or TLS errors as + * causes. + * @returns The error with the causes it names. + */ +export function describeError(error: unknown, depth = 0): string { + return error instanceof Error && error.cause != null && depth < MAX_CAUSES + ? `${String(error)} (cause: ${describeError(error.cause, depth + 1)})` + : String(error); +} + +/** + * Keep the remote response of an HTTP failure, and the causes of any other. + * @returns What the failed attempt shows. + */ +export function failureOf(error: unknown): Failure { + return error instanceof SendActivityError + ? { + statusCode: + error.statusCode >= 100 && error.statusCode <= 599 + ? error.statusCode + : null, + error: error.message, + responseBody: error.responseBody, + } + : { statusCode: null, error: describeError(error), responseBody: null }; +} + +/** What Fedify's outbox worker reported while handling one message. */ +export interface QueuedAttempt { + readonly activityIri: string; + readonly inboxUrl: string; + /** The log the message belongs to, if it names one. */ + readonly logId?: Uuid; + failure?: Failure; + permanent?: Failure; + /** Fedify reported `activitypub.activity.sent` for the inbox. */ + sent?: boolean; + /** What the remote inbox answered the attempt. */ + statusCode?: number | null; + /** The `activitypub.outbox.activity` results Fedify measured. */ + outcomes?: readonly string[]; +} + +/** The log a queued message belongs to, carried with it through retries. */ +const LOG_ID = "drfedActivityLogId"; + +interface OutboxMessage { + readonly type: "outbox"; + readonly activityId?: string; + readonly inbox: string; + readonly [LOG_ID]?: unknown; +} + +interface InboxMessage { + readonly type: "inbox"; + readonly activity?: unknown; + readonly [LOG_ID]?: unknown; +} + +const attempts = new AsyncLocalStorage(); +const receptions = new AsyncLocalStorage<{ + readonly activityIri: string | undefined; + readonly logId: Uuid; +}>(); +/** A delivery being run, which notes the outbox message enqueued for it. */ +export interface Delivery { + readonly logId: Uuid; + readonly inboxUrl: string; + /** Whether an outbox message to the inbox has been enqueued. */ + enqueued: boolean; +} + +const deliveries = new AsyncLocalStorage(); + +const typed = (message: unknown, type: string): boolean => + typeof message === "object" && + message != null && + (message as { type?: unknown }).type === type; +const isOutbox = (message: unknown): message is OutboxMessage => + typed(message, "outbox"); +const isInbox = (message: unknown): message is InboxMessage => + typed(message, "inbox"); +const inboxActivityIri = (message: InboxMessage): string | undefined => { + const { activity } = message; + const id = + typeof activity === "object" && activity != null + ? (activity as { id?: unknown }).id + : undefined; + return typeof id === "string" ? id : undefined; +}; +const loggedIn = (message: { readonly [LOG_ID]?: unknown }) => { + const logId = message[LOG_ID]; + return typeof logId === "string" && validateUuid(logId) ? logId : undefined; +}; + +/** + * Run a delivery so that the outbox messages it enqueues name its log, which + * lets each attempt settle that log even if the activity is sent twice, and + * so that the delivery knows whether one was enqueued at all. + * @returns The result of the run. + */ +export function withDelivery( + delivery: Delivery, + run: () => Promise, +): Promise { + return deliveries.run(delivery, run); +} + +/** + * The log a message being enqueued belongs to: the delivery or the inbox + * request enqueuing it, or, for a retry, the message being handled. + * @returns The log ID, if any. + */ +function logOf(message: unknown): Uuid | undefined { + if (isOutbox(message)) { + const delivery = deliveries.getStore(); + if (delivery != null && message.inbox === delivery.inboxUrl) { + delivery.enqueued = true; + return delivery.logId; + } + const attempt = attempts.getStore(); + return attempt != null && + message.activityId === attempt.activityIri && + message.inbox === attempt.inboxUrl + ? attempt.logId + : undefined; + } + if (isInbox(message)) { + const reception = receptions.getStore(); + if (reception != null) { + return inboxActivityIri(message) === reception.activityIri + ? reception.logId + : undefined; + } + return tracking()?.inboundLogId; + } + return undefined; +} + +function tag(message: unknown): unknown { + const logId = logOf(message); + return logId == null ? message : { ...(message as object), [LOG_ID]: logId }; +} + +const RECEIVED_TTL = Temporal.Duration.from({ hours: 1 }); +const receivedKey = (logId: Uuid): KvKey => [ + "drfed", + "activityLog", + "received", + logId, +]; + +async function receive(db: Database, kv: KvStore, logId: Uuid): Promise { + try { + // Mark first: the inbox request may not have recorded its log yet, and it + // looks for the mark once it has. + await kv.set(receivedKey(logId), true, { ttl: RECEIVED_TTL }); + await receiveInbound(db, logId); + } catch (error) { + logger.error("Could not record a queued inbox reception: {error}", { + error, + }); + } +} + +/** + * Settle an inbound log recorded after the queue worker already ran its inbox + * listener, which left a mark for it. + */ +export async function receivedMeanwhile( + db: Database, + kv: KvStore, + logId: Uuid, +): Promise { + if ((await kv.get(receivedKey(logId))) === true) { + await receiveInbound(db, logId); + } +} + +/** Note an outbox failure on the attempt the worker is making. */ +export const reportOutboxError: OutboxErrorHandler = (error) => { + const attempt = attempts.getStore(); + if (attempt != null) attempt.failure = failureOf(error); +}; + +/** Note that the worker gave up on the attempt it is making. */ +export const reportPermanentFailure: OutboxPermanentFailureHandler = ( + _ctx, + values, +) => { + const attempt = attempts.getStore(); + if (attempt == null) return; + attempt.permanent = + values.reason === "http" + ? failureOf(values.error) + : { statusCode: null, error: values.error.message, responseBody: null }; +}; + +/** + * Translate what the worker reported into settlements, in order. Success is + * what Fedify reports as sent, and giving up what it measures as `abandoned`. + * A message held back before sending made no attempt. + * @returns The settlements to apply to the delivery. + */ +export function queuedSettlements({ + failure, + permanent, + sent = false, + statusCode = null, + outcomes = [], +}: QueuedAttempt): Settlement[] { + if (sent) return [{ status: "sent", attempted: true, statusCode }]; + const failed: Settlement[] = + failure == null + ? [] + : [ + { + ...failure, + status: + permanent == null && outcomes.includes("abandoned") + ? "abandoned" + : "failed", + attempted: true, + }, + ]; + return permanent == null + ? failed + : [ + ...failed, + { ...permanent, status: "permanently_failed", attempted: false }, + ]; +} + +/** + * Whether Fedify reported `activitypub.activity.sent` for the inbox, which is + * what makes a delivery sent: Fedify returns without sending to a recipient it + * leaves out. + * @returns Whether the activity was sent to the inbox. + */ +export const reportsSent = ( + spans: readonly ObservedSpan[], + inboxUrl: string, +): boolean => + spans.some( + (span) => + span.name === "activitypub.send_activity" && + span.events.some( + (event) => + event.name === "activitypub.activity.sent" && + event.attributes["activitypub.inbox.url"] === inboxUrl, + ), + ); + +/** + * Where a response sends the request it answered. + * @returns The URLs the next request may have, empty unless it redirects. + */ +const redirection = ({ status, locations }: ObservedResponse) => + status >= 300 && status < 400 ? locations : []; + +/** + * The status a delivery ended with: that of the last response from the inbox, + * or from wherever the inbox redirected the delivery, however many times. + * @returns The status code, or null when no response came. + */ +export const deliveredStatus = ( + responses: readonly ObservedResponse[], + inboxUrl: string, +): number | null => + responses.reduce<{ + readonly urls: readonly string[]; + readonly status: number | null; + }>( + (hop, response) => { + if (!hop.urls.includes(response.url)) return hop; + const next = redirection(response); + return { + urls: next.length > 0 ? next : hop.urls, + status: response.status, + }; + }, + { urls: [inboxUrl], status: null }, + ).status; + +async function settleQueued( + db: Database, + attempt: QueuedAttempt, + logId: Uuid | undefined, +): Promise { + try { + for (const settlement of queuedSettlements(attempt)) { + // oxlint-disable-next-line no-await-in-loop + await settleOutbound(db, { + ...settlement, + activityIri: attempt.activityIri, + inboxUrl: attempt.inboxUrl, + ...(logId == null ? {} : { id: logId }), + }); + } + } catch (error) { + logger.error("Could not settle a queued delivery: {error}", { error }); + } +} + +async function handleInbox( + db: Database, + kv: KvStore, + message: InboxMessage, + logId: Uuid, + handler: (message: unknown) => Promise | void, +): Promise { + const reception = { activityIri: inboxActivityIri(message), logId }; + const { handled } = await receptions.run(reception, () => + trackRequest(async () => await handler(message)), + ); + if (handled) await receive(db, kv, logId); +} + +async function handle( + db: Database, + kv: KvStore, + message: unknown, + handler: (message: unknown) => Promise | void, +): Promise { + const logged = isInbox(message) ? loggedIn(message) : undefined; + if (isInbox(message) && logged != null) { + await handleInbox(db, kv, message, logged, handler); + return; + } + if (!isOutbox(message) || message.activityId == null) { + await handler(message); + return; + } + const id = loggedIn(message); + const attempt: QueuedAttempt = { + activityIri: message.activityId, + inboxUrl: message.inbox, + ...(id == null ? {} : { logId: id }), + }; + try { + const { spans, outbox, responses } = await attempts.run(attempt, () => + trackRequest(async () => await handler(message)), + ); + attempt.sent = reportsSent(spans, message.inbox); + attempt.statusCode = deliveredStatus(responses, message.inbox); + attempt.outcomes = outbox; + } catch (error) { + attempt.failure ??= failureOf(error); + await settleQueued(db, attempt, id); + throw error; + } + await settleQueued(db, attempt, id); +} + +function observeQueue( + db: Database, + kv: KvStore, + queue: MessageQueue, +): MessageQueue { + return new Proxy(queue, { + get(target, property) { + // Fedify retries by its own policy, measuring each retry and giving up; + // a queue retrying by itself would give up without telling anyone. + if (property === "nativeRetrial") return false; + if (property === "enqueue") { + return async ( + message: unknown, + options?: Parameters[1], + ) => { + await target.enqueue(tag(message), options); + }; + } + if (property === "enqueueMany" && target.enqueueMany != null) { + const enqueueMany = target.enqueueMany.bind(target); + return async ( + messages: readonly unknown[], + options?: Parameters[1], + ) => { + await enqueueMany(messages.map(tag), options); + }; + } + if (property === "listen") { + // Workers outlive whatever request started them; keep them outside it. + return ( + handler: (message: unknown) => Promise | void, + options?: Parameters[1], + ) => + untracked(() => + deliveries.exit(() => + attempts.exit(() => + target.listen( + (message) => handle(db, kv, message, handler), + options, + ), + ), + ), + ); + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +/** + * The outbox queue among Fedify's queue options. + * @returns The queue outgoing activities wait in, if any. + */ +export function outboxQueue( + queue: FederationQueueOptions | MessageQueue | undefined, +): MessageQueue | undefined { + return queue == null || ("enqueue" in queue && "listen" in queue) + ? queue + : queue.outbox; +} + +/** + * Observe what Fedify's queue workers do with each message: each delivery + * attempt settles its outbound log, and an inbox listener run settles the + * inbound log of the request that enqueued it. The queues report no native + * retrial, so that Fedify's own policy retries. Queues shared among roles + * stay shared. + * @returns Queue options to pass to Fedify instead. + */ +export function observeQueues( + db: Database, + kv: KvStore, + queue: FederationQueueOptions | MessageQueue, +): FederationQueueOptions | MessageQueue { + if ("enqueue" in queue && "listen" in queue) { + return observeQueue(db, kv, queue); + } + const observed = new Map(); + const wrap = (value: MessageQueue) => { + const known = observed.get(value); + if (known != null) return known; + const created = observeQueue(db, kv, value); + observed.set(value, created); + return created; + }; + return Object.fromEntries( + Object.entries(queue).map(([role, value]) => [ + role, + value == null ? value : wrap(value as MessageQueue), + ]), + ) as FederationQueueOptions; +} diff --git a/packages/graphql/src/activity-log/remote.test.ts b/packages/graphql/src/activity-log/remote.test.ts new file mode 100644 index 0000000..8e95677 --- /dev/null +++ b/packages/graphql/src/activity-log/remote.test.ts @@ -0,0 +1,75 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// A remote inbox served on a local port, for tests that run Fedify's own +// delivery against it. +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +/** A response the inbox answers with: a status, a body, and a `Location`. */ +export type InboxResponse = readonly [number, string, string?]; + +/** A request the inbox received. */ +export interface InboxRequest { + readonly method: string; + readonly url: string; + readonly body: string; +} + +/** + * Serve an inbox answering each request, whatever its path, with the next + * response, and keeping each request it received. + * With no responses, the port is closed and every connection is refused. + * @returns The result of the run. + */ +export async function withInbox( + responses: readonly InboxResponse[] | null, + run: (inbox: URL, received: readonly InboxRequest[]) => Promise, +): Promise { + const pending = [...(responses ?? [])]; + const received: InboxRequest[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + received.push({ + method: request.method ?? "", + url: request.url ?? "", + body: Buffer.concat(chunks).toString("utf8"), + }); + const [status, body, location] = pending.shift() ?? [500, "unexpected"]; + response.statusCode = status; + if (location != null) response.setHeader("Location", location); + response.end(body); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const inbox = new URL(`http://127.0.0.1:${port}/inbox`); + const close = async () => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }; + if (responses == null) await close(); + try { + return await run(inbox, received); + } finally { + if (responses != null) await close(); + } +} diff --git a/packages/graphql/src/activity-log/telemetry.ts b/packages/graphql/src/activity-log/telemetry.ts new file mode 100644 index 0000000..ef7be81 --- /dev/null +++ b/packages/graphql/src/activity-log/telemetry.ts @@ -0,0 +1,346 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import diagnostics from "node:diagnostics_channel"; + +import { + type Attributes, + type Counter, + type Histogram, + type Meter, + type MeterProvider, + type Span, + SpanStatusCode, + type Tracer, + type TracerProvider, +} from "@opentelemetry/api"; + +import { + type ObservedKeyFetch, + type ObservedKeyLookup, + type ObservedSpan, + type Tracked, + tracking, +} from "./tracking.ts"; + +/** The spans whose attributes and events Fedify documents as its report. */ +const REPORTED_SPANS: ReadonlySet = new Set([ + "activitypub.inbox", + "activitypub.send_activity", + "http_signatures.verify", + "ld_signatures.verify", + "object_integrity_proofs.verify", +]); +const SIGNATURE_METRIC = "activitypub.signature.verification.duration"; +const KEY_FETCH_METRIC = "activitypub.signature.key_fetch.duration"; +const KEY_LOOKUP_METRIC = "activitypub.key.lookup"; +const OUTBOX_METRIC = "activitypub.outbox.activity"; + +const bindTo = (target: object, property: string | symbol): unknown => { + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; +}; + +function observeSpan(name: string, span: Span): Span { + const state = tracking(); + if (state == null || !REPORTED_SPANS.has(name)) return span; + const attributes = new Map(); + const events: ObservedSpan["events"][number][] = []; + const observed = { name, attributes, events, failed: false }; + state.spans.push(observed); + const proxy: Span = new Proxy(span, { + get(target, property) { + switch (property) { + // Fedify records some attributes only on recording spans. + case "isRecording": + return () => true; + case "setAttribute": + return (key: string, value: Parameters[1]) => { + attributes.set(key, value); + target.setAttribute(key, value); + return proxy; + }; + case "setAttributes": + return (values: Attributes) => { + for (const [key, value] of Object.entries(values)) { + attributes.set(key, value); + } + target.setAttributes(values); + return proxy; + }; + case "addEvent": + return (event: string, ...rest: unknown[]) => { + const [values] = rest; + events.push({ + name: event, + attributes: + values != null && + typeof values === "object" && + !Array.isArray(values) && + !(values instanceof Date) + ? (values as Attributes) + : {}, + }); + (target.addEvent as (...args: unknown[]) => Span)(event, ...rest); + return proxy; + }; + case "setStatus": + return (status: Parameters[0]) => { + if (status.code === SpanStatusCode.ERROR) observed.failed = true; + target.setStatus(status); + return proxy; + }; + default: + return bindTo(target, property); + } + }, + }); + return proxy; +} + +function observeTracer(tracer: Tracer): Tracer { + return { + startSpan: (name, options, context) => + observeSpan(name, tracer.startSpan(name, options, context)), + startActiveSpan: ((name: string, ...rest: unknown[]) => { + const run = rest.pop() as (span: Span) => unknown; + return (tracer.startActiveSpan as (...args: unknown[]) => unknown)( + name, + ...rest, + (span: Span) => run(observeSpan(name, span)), + ); + }) as Tracer["startActiveSpan"], + }; +} + +/** + * Let `trackRequest()` see the spans Fedify reports, while passing them on to + * `provider` unchanged. + * @returns A tracer provider to give Fedify instead. + */ +export function trackSpans(provider: TracerProvider): TracerProvider { + return { + getTracer: (name, version, options) => + observeTracer(provider.getTracer(name, version, options)), + }; +} + +const text = (value: unknown): string => + typeof value === "string" ? value : ""; + +/** What a measurement adds to the tracked run it was made in. */ +type Note = (state: Tracked, attributes: Attributes | undefined) => void; + +/** + * The key lookup and fetches Fedify measured whose verification it has not + * measured yet. It reads and writes the public-key cache and counts a lookup, + * then measures the fetch that made them, and then the verification that + * fetched. + */ +interface Pending { + lookup: ObservedKeyLookup | null; + fetches: { readonly kind: string; readonly fetch: ObservedKeyFetch }[]; +} + +const pending = new WeakMap(); + +function pendingOf(state: Tracked): Pending { + const known = pending.get(state); + if (known != null) return known; + const created: Pending = { lookup: null, fetches: [] }; + pending.set(state, created); + return created; +} + +const noteKeyLookup: Note = (state, attributes) => { + const status = attributes?.["http.response.status_code"]; + pendingOf(state).lookup = { + result: text(attributes?.["activitypub.lookup.result"]), + statusCode: typeof status === "number" ? status : null, + }; +}; + +const noteKeyFetch: Note = (state, attributes) => { + const waiting = pendingOf(state); + waiting.fetches.push({ + kind: text(attributes?.["activitypub.signature.kind"]), + fetch: { + result: text(attributes?.["activitypub.signature.key_fetch.result"]), + lookup: waiting.lookup, + keys: state.keys, + }, + }); + waiting.lookup = null; + state.keys = new Map(); +}; + +const noteVerification: Note = (state, attributes) => { + const waiting = pendingOf(state); + const kind = text(attributes?.["activitypub.signature.kind"]); + state.verifications.push({ + kind, + result: text(attributes?.["activitypub.signature.result"]), + keyFetches: waiting.fetches + .filter((fetched) => fetched.kind === kind) + .map(({ fetch }) => fetch), + }); + waiting.fetches = waiting.fetches.filter((fetched) => fetched.kind !== kind); +}; + +const noteOutbox: Note = (state, attributes) => { + state.outbox.push(text(attributes?.["activitypub.processing.result"])); +}; + +/** The instruments whose measurements the tracked run keeps, by how made. */ +const NOTES = { + createHistogram: { + method: "record", + notes: new Map([ + [SIGNATURE_METRIC, noteVerification], + [KEY_FETCH_METRIC, noteKeyFetch], + ]), + }, + createCounter: { + method: "add", + notes: new Map([ + [OUTBOX_METRIC, noteOutbox], + [KEY_LOOKUP_METRIC, noteKeyLookup], + ]), + }, +} as const; + +function observeInstrument( + instrument: T, + method: "add" | "record", + note: Note, +): T { + return new Proxy(instrument, { + get(target, property) { + if (property !== method) return bindTo(target, property); + return (value: number, attributes?: Attributes, ...rest: unknown[]) => { + const state = tracking(); + if (state != null) note(state, attributes); + (bindTo(target, method) as (...args: unknown[]) => void)( + value, + attributes, + ...rest, + ); + }; + }, + }); +} + +function observeMeter(meter: Meter): Meter { + return new Proxy(meter, { + get(target, property) { + if (property !== "createHistogram" && property !== "createCounter") { + return bindTo(target, property); + } + const { method, notes } = NOTES[property]; + return (name: string, ...rest: unknown[]) => { + const instrument = ( + bindTo(target, property) as ( + ...args: unknown[] + ) => Counter | Histogram + )(name, ...rest); + const note = notes.get(name); + return note == null + ? instrument + : observeInstrument(instrument, method, note); + }; + }, + }); +} + +/** + * Let `trackRequest()` see the signature verifications, with the key fetches + * each made and the keys `trackPublicKeys()` saw each bring, and the outbox + * outcomes Fedify measures, while passing them on to `provider` unchanged. + * @returns A meter provider to give Fedify instead. + */ +export function trackMetrics(provider: MeterProvider): MeterProvider { + return { + getMeter: (name, version, options) => + observeMeter(provider.getMeter(name, version, options)), + }; +} + +interface UndiciRequest { + readonly method: string; + readonly origin: string; + readonly path: string; +} + +interface UndiciResponse { + readonly statusCode: number; + /** Header names and values, in turn. */ + readonly headers: readonly Buffer[]; +} + +const header = (value: Buffer): string => value.toString("latin1"); +const ascii = (value: Buffer): boolean => value.every((byte) => byte < 0x80); + +/** + * The URLs a response's `Location` may send the request following it to, + * spelled as that request is: resolved against the URL answered, without a + * fragment. RFC 9110 keeps the value within ASCII; one outside it is read as + * Latin-1 by Fedify, which follows a redirect itself when it signs the + * request, and as UTF-8 by `fetch()`, which follows it otherwise. + * @returns The URLs, without one that does not parse. + */ +function locationsOf(headers: readonly Buffer[], base: string): string[] { + const index = headers.findIndex( + (name, position) => + position % 2 === 0 && header(name).toLowerCase() === "location", + ); + const value = index < 0 ? undefined : headers[index + 1]; + if (value == null) return []; + const readings = ascii(value) + ? [header(value)] + : [header(value), value.toString("utf8")]; + const urls = readings.flatMap((reading) => { + const url = URL.parse(reading, base); + if (url == null) return []; + url.hash = ""; + return [url.href]; + }); + return [...new Set(urls)]; +} + +// `fetch()` creates each request in its caller's context, but a response on a +// reused connection arrives in the connection's, so requests carry the run. +const requests = new WeakMap(); +diagnostics.subscribe("undici:request:create", (message) => { + const state = tracking(); + if (state != null) { + requests.set((message as { request: UndiciRequest }).request, state); + } +}); +diagnostics.subscribe("undici:request:headers", (message) => { + const { request, response } = message as { + readonly request: UndiciRequest; + readonly response: UndiciResponse; + }; + const state = requests.get(request); + if (state == null || state.closed) return; + const url = new URL(request.path, request.origin).href; + state.responses.push({ + method: request.method, + url, + status: response.statusCode, + locations: locationsOf(response.headers, url), + }); +}); diff --git a/packages/graphql/src/activity-log/tracking.ts b/packages/graphql/src/activity-log/tracking.ts new file mode 100644 index 0000000..78f1621 --- /dev/null +++ b/packages/graphql/src/activity-log/tracking.ts @@ -0,0 +1,264 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { Uuid } from "@drfed/models/uuid"; +import type { Federation, KvKey, KvStore } from "@fedify/fedify"; +import type { Attributes } from "@opentelemetry/api"; + +/** A span Fedify reported while a tracked run went on. */ +export interface ObservedSpan { + readonly name: string; + readonly attributes: ReadonlyMap; + readonly events: readonly { + readonly name: string; + readonly attributes: Readonly; + }[]; + /** Whether the span ended with an error status. */ + readonly failed: boolean; +} + +/** One `activitypub.key.lookup` measurement. */ +export interface ObservedKeyLookup { + /** `hit`, `fetched`, `not_found`, `invalid`, `network_error`, or `error`. */ + readonly result: string; + /** The status the server of the key answered with, if it answered. */ + readonly statusCode: number | null; +} + +/** One `activitypub.signature.key_fetch.duration` measurement. */ +export interface ObservedKeyFetch { + /** `hit`, `fetched`, or `error` when no usable key came back. */ + readonly result: string; + /** The lookup Fedify counted for the fetch, which tells why it failed. */ + readonly lookup: ObservedKeyLookup | null; + /** + * Every value each public-key cache entry held while Fedify made the fetch, + * in order, by its key below the prefix, as JSON: what it read, then what it + * wrote. The last of a fetch that brought a key is the key it brought. + */ + readonly keys: ReadonlyMap; +} + +/** One `activitypub.signature.verification.duration` measurement. */ +export interface ObservedVerification { + /** `http`, `linked_data`, or `object_integrity`. */ + readonly kind: string; + /** `verified`, `rejected`, `missing`, or `error`. */ + readonly result: string; + /** + * The key fetches Fedify measured while verifying, in order, which hold the + * keys this verification used, and no other. It fetches a key again when + * the cached one did not verify. + */ + readonly keyFetches: readonly ObservedKeyFetch[]; +} + +/** One response `fetch()` received while a tracked run went on. */ +export interface ObservedResponse { + readonly method: string; + readonly url: string; + readonly status: number; + /** + * The URLs its `Location` header may name, without a fragment: one, or two + * for a value outside ASCII, which a follower reads as Latin-1 or as UTF-8. + */ + readonly locations: readonly string[]; +} + +/** What a tracked run did, besides its result. */ +export interface Report { + /** Whether an inbox listener ran. */ + readonly handled: boolean; + readonly spans: readonly ObservedSpan[]; + readonly verifications: readonly ObservedVerification[]; + /** `activitypub.outbox.activity` results: `retried`, `abandoned`, ... */ + readonly outbox: readonly string[]; + readonly responses: readonly ObservedResponse[]; +} + +/** The state of a tracked run, which Fedify's reports add to. */ +export interface Tracked { + /** The inbound log the run will be recorded as, known before it exists. */ + readonly inboundLogId?: Uuid; + handled: boolean; + /** Tasks the run started may outlive it; they must not add to it. */ + closed: boolean; + /** + * The values public-key cache entries held since Fedify last measured a key + * fetch, which the next measurement takes as those of its fetch. + */ + keys: Map; + readonly spans: ObservedSpan[]; + readonly verifications: ObservedVerification[]; + readonly outbox: string[]; + readonly responses: ObservedResponse[]; +} + +declare const tracked: unique symbol; + +/** + * A federation made by `createFederation()`, whose reports `trackRequest()` + * can see. + */ +export type TrackedFederation = Federation & { + readonly [tracked]: true; +}; + +const federationKv = Symbol("drfed.federationKv"); + +/** Remember the KV store a federation made by `createFederation()` uses. */ +export function attachKv(federation: Federation, kv: KvStore): void { + Object.defineProperty(federation, federationKv, { value: kv }); +} + +/** + * The KV store `createFederation()` gave the federation, if it made it. + * @returns The store, or undefined. + */ +export function kvOf(federation: Federation): KvStore | undefined { + return (federation as { [federationKv]?: KvStore })[federationKv]; +} + +const storage = new AsyncLocalStorage(); + +/** + * The tracked run in progress, if any. + * @returns Its state, or undefined outside one or after it ended. + */ +export function tracking(): Tracked | undefined { + const state = storage.getStore(); + return state?.closed === false ? state : undefined; +} + +/** Mark the tracked request as having reached an inbox listener. */ +export function markHandled(): void { + const state = tracking(); + if (state != null) state.handled = true; +} + +/** + * Run something that outlives the current tracked run, such as a queue + * worker, outside it. + * @returns The result of the run. + */ +export function untracked(run: () => T): T { + return storage.exit(run); +} + +interface TrackOptions { + readonly inboundLogId?: Uuid; +} + +/** + * Run a federation request or a queued task and report what Fedify did: the + * spans, measurements and responses it reported, with the values public-key + * cache entries held at each key fetch it measured, even when the run throws. + * @returns How the run ended, and what was tracked. + */ +export async function trackSettled( + run: () => Promise, + { inboundLogId }: TrackOptions = {}, +): Promise<{ readonly outcome: PromiseSettledResult } & Report> { + const state: Tracked = { + ...(inboundLogId == null ? {} : { inboundLogId }), + handled: false, + closed: false, + keys: new Map(), + spans: [], + verifications: [], + outbox: [], + responses: [], + }; + const [outcome] = await Promise.allSettled([ + storage.run(state, async () => await run()), + ]); + state.closed = true; + const { handled, spans, verifications, outbox, responses } = state; + return { outcome, handled, spans, verifications, outbox, responses }; +} + +/** + * End as a settled run did. + * @returns The result of the run, unless it threw, which is thrown again. + */ +export function unwrap(outcome: PromiseSettledResult): T { + if (outcome.status === "rejected") throw outcome.reason; + return outcome.value; +} + +/** + * Run as `trackSettled()` does, for a caller that needs no report of a run + * that throws. + * @returns The result of the run, and what was tracked. + */ +export async function trackRequest( + run: () => Promise, + options: TrackOptions = {}, +): Promise<{ readonly result: T } & Report> { + const { outcome, ...report } = await trackSettled(run, options); + return { result: unwrap(outcome), ...report }; +} + +const below = (key: KvKey, prefix: KvKey): string | null => + key.length > prefix.length && + prefix.every((part, index) => key[index] === part) + ? JSON.stringify(key.slice(prefix.length)) + : null; + +/** + * Let `trackRequest()` see the public keys Fedify reads and writes, which are + * the keys it verifies with, including those it fetches again. `trackMetrics()` + * tells which key fetch, and so which verification, each belongs to. + * @returns The same store, tracking entries under `prefix`. + */ +export function trackPublicKeys(kv: KvStore, prefix: KvKey): KvStore { + const note = (key: KvKey, value: unknown) => { + const entry = below(key, prefix); + const keys = tracking()?.keys; + if (entry != null) keys?.set(entry, [...(keys.get(entry) ?? []), value]); + }; + return new Proxy(kv, { + get(target, property) { + if (property === "get") { + return async (key: KvKey) => { + const value = await target.get(key); + note(key, value); + return value; + }; + } + if (property === "set") { + return async ( + key: KvKey, + value: unknown, + options?: Parameters[2], + ) => { + await target.set(key, value, options); + note(key, value); + }; + } + if (property === "delete") { + return async (key: KvKey) => { + await target.delete(key); + note(key, undefined); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/packages/graphql/src/activity-log/verification.ts b/packages/graphql/src/activity-log/verification.ts new file mode 100644 index 0000000..4494c73 --- /dev/null +++ b/packages/graphql/src/activity-log/verification.ts @@ -0,0 +1,388 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Database } from "@drfed/models"; +import { observeKeyVersion } from "@drfed/models/key"; +import type { + ActivityLogVerificationMechanism, + ActivityLogVerificationResult, +} from "@drfed/models/schema"; +import type { Uuid } from "@drfed/models/uuid"; +import { detachSignature, exportJwk } from "@fedify/fedify"; +import { Activity, type DocumentLoader } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; + +import { iri } from "./describe.ts"; +import { trackedKey } from "./keycache.ts"; +import type { + ObservedKeyFetch, + ObservedSpan, + ObservedVerification, + Report, +} from "./tracking.ts"; + +const logger = getLogger(["drfed", "graphql", "activity-log"]); + +export interface VerificationObservation { + readonly mechanism: ActivityLogVerificationMechanism | null; + readonly result: ActivityLogVerificationResult; + readonly keyId: Uuid | null; + readonly signedKeyIri: string | null; + readonly detail: string | null; +} + +/** What Fedify's report says of a verification. */ +export interface Verdict { + readonly mechanism: ActivityLogVerificationMechanism | null; + /** + * Whether the signature or proof verified, which is not whether Fedify took + * it to authenticate the activity, nor whether it accepted the activity. + */ + readonly result: ActivityLogVerificationResult; + readonly keyIri?: string | null; + /** + * The key fetches Fedify measured for the one verification the verdict is + * of, which hold the key it used. + */ + readonly keyFetches?: readonly ObservedKeyFetch[]; + readonly detail?: string | null; +} + +/** + * The signatures and proofs a document carries, whether or not tried; they + * count as tried only when Fedify went on to HTTP signatures. + */ +export interface Carried { + /** The key of a Linked Data Signature Fedify would try, if any. */ + readonly ldKeyIri: string | null; + /** The verification method of each proof; null when not known. */ + readonly proofMethods: readonly (string | null)[]; +} + +/** + * Mirror of the check in Fedify's inbox handler, which is not exported. + * @returns Whether Fedify would try Linked Data Signatures on the document. + */ +export function hasLdSignature( + json: unknown, +): json is { signature: { creator: string } } { + if (typeof json !== "object" || json == null || !("signature" in json)) { + return false; + } + const { signature } = json; + return ( + typeof signature === "object" && + signature != null && + "type" in signature && + signature.type === "RsaSignature2017" && + "creator" in signature && + typeof signature.creator === "string" && + "created" in signature && + typeof signature.created === "string" && + "signatureValue" in signature && + typeof signature.signatureValue === "string" + ); +} + +/** + * Read the `keyId` an HTTP signature declares, without verifying. + * @returns The declared key IRI, or null when it is missing or not a URL. + */ +export function declaredKeyId(headers: Headers): string | null { + const authorization = headers.get("authorization"); + const candidates = [ + headers.get("signature-input"), + headers.get("signature"), + authorization != null && /^signature\s/iu.test(authorization) + ? authorization + : null, + ]; + for (const value of candidates) { + const keyId = value?.match(/keyid="(?[^"]*)"/iu)?.groups?.keyId; + if (keyId != null) return iri(keyId); + } + return null; +} + +const refuse: DocumentLoader = () => + Promise.reject(new TypeError("Documents are not fetched while observing.")); + +/** + * Find the proofs of a document as JSON-LD, as Fedify does, however it spells + * `proof`, and without fetching proofs it only references. + * @returns The verification method of each proof. + */ +export async function proofMethods( + json: unknown, + contextLoader: DocumentLoader, +): Promise<(string | null)[]> { + const options = { contextLoader, documentLoader: refuse }; + const activity = await Activity.fromJsonLd(json, options).catch(() => null); + if (activity == null) return []; + const embedded: (string | null)[] = []; + for await (const proof of activity.getProofs({ + ...options, + suppressError: true, + })) { + embedded.push(proof.verificationMethodId?.href ?? null); + } + return [...embedded, ...activity.proofIds.map(() => null)]; +} + +const reportedIri = (span: ObservedSpan | undefined, attribute: string) => + iri(span?.attributes.get(attribute)); + +const failure = (kind: string, result: string | undefined) => + result === "error" ? `Fedify could not verify the ${kind}.` : null; + +/** + * Why the last key fetch of a verification brought no usable key, which left + * the signature unchecked, or checked only against a cached key that did not + * verify. Fedify counts the lookup behind the fetch. + * @returns The status the server of the key answered with, `cached` for the + * record of an earlier failure, or otherwise the + * `activitypub.lookup.result`; null when the fetch brought a key, or + * none was made. + */ +function keyFetchFailure( + measured: ObservedVerification | undefined, +): string | null { + const fetched = measured?.keyFetches.at(-1); + if (fetched?.result !== "error") return null; + const { lookup } = fetched; + if (lookup == null) return "error"; + if (lookup.statusCode != null) return String(lookup.statusCode); + return lookup.result === "hit" ? "cached" : lookup.result; +} + +const accepted: Pick = { + result: "verified", + detail: null, +}; + +/** + * Fedify takes proofs to authenticate an activity only when the controllers + * of their keys cover every actor and attribution it names. + */ +const unauthenticated = + "Every Object Integrity Proof Fedify tried verified, but it did not " + + "accept them as authenticating the activity."; + +/** + * Tell a verification that failed for want of a key from one whose signature + * did not verify. + * @param kind What was verified, as the detail of a verification that threw + * names it. + * @returns The result and detail of the refusal. + */ +function refusal( + kind: string, + measured: ObservedVerification | undefined, +): Pick { + const cause = keyFetchFailure(measured); + return cause == null + ? { result: "invalid_signature", detail: failure(kind, measured?.result) } + : { result: "key_fetch_error", detail: `keyFetchError: ${cause}` }; +} + +/** + * Read an `http_signatures.verify` span. Fedify tries HTTP signatures only + * after the other mechanisms failed, so without an HTTP signature the verdict + * is that of the mechanism tried before. + * @param measured What Fedify measured of the verification. + * @returns The verdict of the HTTP signature, or of the mechanism before it. + */ +function httpVerdict( + http: ObservedSpan, + before: Verdict | null, + measured: ObservedVerification | undefined, +): Verdict { + const tried = { + mechanism: "http_signature", + keyIri: reportedIri(http, "http_signatures.key_id"), + keyFetches: measured?.keyFetches ?? [], + } as const; + if (http.attributes.get("http_signatures.verified") === true) { + return { ...tried, result: "verified" }; + } + switch (http.attributes.get("http_signatures.failure_reason")) { + case "noSignature": + return before ?? { mechanism: tried.mechanism, result: "no_signature" }; + case "keyFetchError": { + const cause = + http.attributes.get("http_signatures.key_fetch_status") ?? + http.attributes.get("http_signatures.key_fetch_error"); + return { + ...tried, + result: "key_fetch_error", + detail: `keyFetchError: ${String(cause)}`, + }; + } + // Fedify names only a fetch that failed; a key document that holds no key + // leaves the signature as unchecked. + default: + return { ...tried, ...refusal("HTTP signature", measured) }; + } +} + +/** + * Read the `activitypub.activity.received` event of the `activitypub.inbox` + * span. + * @returns Whether Fedify went on to handle the activity as a verified one. + */ +const receivedAsVerified = (spans: readonly ObservedSpan[]): boolean => + spans + .find((span) => span.name === "activitypub.inbox") + ?.events.find((event) => event.name === "activitypub.activity.received") + ?.attributes["activitypub.activity.verified"] === true; + +/** + * Read the verification out of what Fedify documents it reports: the + * `activitypub.signature.verification.duration` result of each mechanism it + * tried, in its order, with the `activitypub.signature.key_fetch.duration` and + * `activitypub.key.lookup` results of the keys it fetched for each, the + * `*.verify` spans naming their keys, and the `activitypub.activity.received` + * event when it went on to handle the activity. A verdict is of whether a + * signature or proof verified: proofs that verify without authenticating the + * activity's actor are verified, whatever Fedify then answered. + * @returns The verdict of the mechanism that verified, or of the last tried, + * with the key fetches of that one verification. + */ +export function reportedVerdict( + { spans, verifications }: Pick, + carried: Carried, +): Verdict { + const ld = spans.find((span) => span.name === "ld_signatures.verify"); + const proofs = spans.filter( + (span) => span.name === "object_integrity_proofs.verify", + ); + const http = spans.findLast((span) => span.name === "http_signatures.verify"); + const measured = (kind: string) => + verifications.filter((measure) => measure.kind === kind); + const ldMeasured = measured("linked_data").at(-1); + const httpMeasured = measured("http").at(-1); + // Fedify reports each proof it tries as a span and as a measurement, in + // turn, and stops at the first proof that fails, so a failure is the last. + const proofsMeasured = measured("object_integrity"); + const ldVerdict = (verified: boolean): Verdict => ({ + mechanism: "ld_signature", + keyIri: reportedIri(ld, "ld_signatures.key_id") ?? carried.ldKeyIri, + keyFetches: ldMeasured?.keyFetches ?? [], + ...(verified ? accepted : refusal("Linked Data Signature", ldMeasured)), + }); + const proofVerdict = (verified: boolean): Verdict => { + const tried = proofsMeasured.find( + ({ result }) => (result === "verified") === verified, + ); + return { + mechanism: "object_integrity_proof", + keyIri: + reportedIri( + proofs.find((proof) => proof.failed === !verified) ?? proofs[0], + "object_integrity_proofs.key_id", + ) ?? + carried.proofMethods.find((method) => method != null) ?? + null, + keyFetches: tried?.keyFetches ?? [], + ...(verified ? accepted : refusal("Object Integrity Proof", tried)), + }; + }; + if (ldMeasured?.result === "verified") return ldVerdict(true); + if (http?.attributes.get("http_signatures.verified") === true) { + return httpVerdict(http, null, httpMeasured); + } + // Fedify goes on to HTTP signatures unless the proofs authenticate the + // activity, which takes more than that every one of them verifies. + if ( + proofsMeasured.length > 0 && + proofsMeasured.every(({ result }) => result === "verified") + ) { + return http == null + ? proofVerdict(true) + : { ...proofVerdict(true), detail: unauthenticated }; + } + // Fedify accepts an activity naming no actor or attribution vacuously. + if (receivedAsVerified(spans)) { + return { mechanism: null, result: "no_signature" }; + } + // Fedify tries HTTP signatures last, so a signature or proof it reported + // nothing of was tried only when it went on to them. + const before = + proofsMeasured.length > 0 || + (http != null && carried.proofMethods.length > 0) + ? proofVerdict(false) + : ldMeasured != null || (http != null && carried.ldKeyIri != null) + ? ldVerdict(false) + : null; + if (http != null) return httpVerdict(http, before, httpMeasured); + return before ?? { mechanism: null, result: "unattempted" }; +} + +/** + * Record what Fedify reported of verifying an inbox request, and the key + * version the verification the verdict is of used, from what `trackRequest()` + * saw it do. + * @param json The parsed body, or undefined when it is not JSON. + * @returns The observation; `unobserved` when observing itself failed. + */ +export async function observeVerification( + db: Database, + headers: Headers, + json: unknown, + report: Pick, + contextLoader: DocumentLoader, +): Promise { + const signedKeyIri = + reportedIri( + report.spans.findLast((span) => span.name === "http_signatures.verify"), + "http_signatures.key_id", + ) ?? declaredKeyId(headers); + try { + const verdict = reportedVerdict(report, { + ldKeyIri: hasLdSignature(json) ? iri(json.signature.creator) : null, + proofMethods: await proofMethods(detachSignature(json), contextLoader), + }); + const key = + verdict.keyIri == null + ? null + : await trackedKey(verdict.keyFetches ?? [], verdict.keyIri, { + contextLoader, + }); + const version = + key?.publicKey == null || verdict.keyIri == null + ? null + : await observeKeyVersion(db, { + iri: verdict.keyIri, + publicKey: await exportJwk(key.publicKey), + }); + return { + mechanism: verdict.mechanism, + result: verdict.result, + keyId: version?.id ?? null, + signedKeyIri, + detail: verdict.detail ?? null, + }; + } catch (error) { + logger.error("Could not observe inbox verification: {error}", { error }); + return { + mechanism: null, + result: "unobserved", + keyId: null, + signedKeyIri, + detail: `Verification observation failed: ${String(error)}`, + }; + } +} diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 1bc213c..72849ba 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -228,7 +228,7 @@ describe("ActivityPub resource origin spelling", () => { new Request(requestIri, { headers: accept }), { contextData: undefined }, ); - assert.equal(response.status, 200); + assert.equal(response.status, resource === "Tombstone" ? 410 : 200); const body = await response.json(); assert.equal(body.id, iri); assert.equal(body.type, resource === "object" ? "Note" : resource); @@ -329,8 +329,8 @@ describe("ActivityPub objects", () => { new Request(object.iri, { headers: accept }), { contextData: undefined }, ); - // Fedify serializes generic object tombstones with HTTP 200. - assert.equal(deleted.status, 200); + // Fedify serves object tombstones with HTTP 410, keeping the body. + assert.equal(deleted.status, 410); const tombstone = await deleted.json(); assert.equal(tombstone.type, "Tombstone"); assert.equal( diff --git a/packages/graphql/src/federation.ts b/packages/graphql/src/federation.ts index 94a2e15..c08270f 100644 --- a/packages/graphql/src/federation.ts +++ b/packages/graphql/src/federation.ts @@ -27,7 +27,6 @@ import type { import { type Uuid, validateUuid } from "@drfed/models/uuid"; import { type Context, - type Federation, type FederationBuilder, type FederationOptions, createFederationBuilder, @@ -49,10 +48,29 @@ import { Tombstone, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; +import { metrics, trace } from "@opentelemetry/api"; import { type SQL, type SQLWrapper, and, eq, sql } from "drizzle-orm"; +import { markQueued } from "./activity-log/outbound.ts"; +import { + observeQueues, + outboxQueue, + reportOutboxError, + reportPermanentFailure, +} from "./activity-log/queue.ts"; +import { trackMetrics, trackSpans } from "./activity-log/telemetry.ts"; +import { + type TrackedFederation, + attachKv, + markHandled, + trackPublicKeys, +} from "./activity-log/tracking.ts"; import { canonicalizeAuthority } from "./origin.ts"; +export { createInboundRecorder } from "./activity-log/inbound.ts"; +export type { TrackedFederation } from "./activity-log/tracking.ts"; +export { deliverActivity } from "./activity-log/outbound.ts"; + /** * The vocabulary object types that DrFed serves as actors. */ @@ -123,6 +141,8 @@ export function buildFederation(db: Database): FederationBuilder { } return toActorObject(ctx, identifier, actor); }) + // FIXME: https://github.com/fedify-dev/drfed/issues/87 + .setKeyPairsDispatcher(() => []) .mapHandle(async (ctx, username) => { const actor = await db.query.actors.findFirst({ where: { @@ -140,6 +160,7 @@ export function buildFederation(db: Database): FederationBuilder { .setInboxListeners("/users/{identifier}/inbox", "/inbox") // FIXME: https://github.com/fedify-dev/drfed/issues/88 .on(Activity, (_ctx, activity) => { + markHandled(); logger.debug("Received an activity: {activity}", { activity }); }) .onError((_ctx, error) => { @@ -312,6 +333,7 @@ export function buildFederation(db: Database): FederationBuilder { }; }, ); + builder.setOutboxPermanentFailureHandler(reportPermanentFailure); return builder; } @@ -319,6 +341,10 @@ export function buildFederation(db: Database): FederationBuilder { * Creates a `Federation` instance with every DrFed dispatcher registered. * Every registration happens on a fresh builder inside this function, so the * returned instance is complete and must not be mutated further. + * The queues, if any, are observed so that each delivery attempt settles its + * outbound log and each queued inbox listener run its inbound log. The public-key cache and the spans and measurements + * Fedify reports are tracked so that `createInboundRecorder()` sees how each + * inbox request was verified, and with which key. * @param db The database to resolve local actors from. * @param options Options for the underlying Fedify `Federation`, such as * the `kv` store. @@ -327,8 +353,30 @@ export function buildFederation(db: Database): FederationBuilder { export default async function createFederation( db: Database, options: FederationOptions, -): Promise> { - return await buildFederation(db).build(options); +): Promise { + const federation = await buildFederation(db).build({ + ...options, + kv: trackPublicKeys( + options.kv, + options.kvPrefixes?.publicKey ?? ["_fedify", "publicKey"], + ), + tracerProvider: trackSpans( + options.tracerProvider ?? trace.getTracerProvider(), + ), + meterProvider: trackMetrics( + options.meterProvider ?? metrics.getMeterProvider(), + ), + ...(options.queue == null + ? {} + : { queue: observeQueues(db, options.kv, options.queue) }), + async onOutboxError(error, activity) { + await reportOutboxError(error, activity); + await options.onOutboxError?.(error, activity); + }, + }); + if (outboxQueue(options.queue) != null) markQueued(federation); + attachKv(federation, options.kv); + return federation as TrackedFederation; } // Whether a sanction is *currently* active is always determined by comparing diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index b5768af..7d2df75 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -28,6 +28,24 @@ const logger = getLogger(["drfed", "graphql", "test"]); const testEndpoint = "https://drfed.test/graphql"; +let databaseSnapshot: Promise | undefined; + +async function createDatabaseSnapshot(): Promise { + const client = new PGlite(); + try { + await client.waitReady; + await migrate({ credentials: { driver: "pglite", client } }); + return await client.dumpDataDir("none"); + } finally { + await client.close(); + } +} + +function getDatabaseSnapshot(): Promise { + databaseSnapshot ??= createDatabaseSnapshot(); + return databaseSnapshot; +} + /** * The `fetch()` function exposed by the test Yoga server. */ @@ -95,9 +113,9 @@ export interface TestHarness { /** * Runs a callback with a fresh in-memory PGlite database. * - * The database is migrated before the callback is invoked, so every table in - * the current `@drfed/models` schema is available. The underlying PGlite - * client is closed after the callback resolves or rejects. + * Each database is restored from a lazily cached, migrated snapshot, so every + * table in the current `@drfed/models` schema is available. The underlying + * PGlite client is closed after the callback resolves or rejects. * * @example * ```ts @@ -123,10 +141,9 @@ export async function withTemporaryDatabase( // oxlint-disable-next-line promise/prefer-await-to-callbacks callback: (db: Database) => Promise | T, ): Promise> { - const client = new PGlite(); + const client = new PGlite({ loadDataDir: await getDatabaseSnapshot() }); try { await client.waitReady; - await migrate({ credentials: { driver: "pglite", client } }); const db: Database = drizzle({ client, relations, schema }); // oxlint-disable-next-line promise/prefer-await-to-callbacks return await callback(db); diff --git a/packages/graphql/src/key.ts b/packages/graphql/src/key.ts new file mode 100644 index 0000000..974e81d --- /dev/null +++ b/packages/graphql/src/key.ts @@ -0,0 +1,64 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import builder, { type DrFedObjectRef } from "./builder.ts"; + +const KeyRef = builder.drizzleNode("keys", { + name: "Key", + description: + "A remote public key, identified by its IRI. Readable by any " + + "authenticated viewer, since it holds public material only.", + authScopes: { authenticated: true }, + runScopesOnType: true, + id: { column: (key) => key.id }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + iri: t.expose("iri", { type: "URL" }), + created: t.expose("created", { type: "DateTime" }), + versions: t.relatedConnection("versions", { + description: "The observed versions of the key, oldest first.", + query: { orderBy: { firstSeen: "asc", id: "asc" } }, + }), + }), +}); +export const Key: DrFedObjectRef = KeyRef; +const observationDescription = + "DrFed observation time, not the remote key rotation time; " + + "does not imply continuous use between observations."; +const KeyVersionRef = builder.drizzleNode("keyVersions", { + name: "KeyVersion", + description: + "Immutable public key material observed under a `Key`. Readable by " + + "any authenticated viewer.", + authScopes: { authenticated: true }, + runScopesOnType: true, + id: { column: (version) => version.id }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + key: t.relation("key"), + publicKey: t.expose("publicKey", { type: "JSON" }), + fingerprint: t.exposeString("fingerprint"), + firstSeen: t.expose("firstSeen", { + type: "DateTime", + description: `First ${observationDescription}`, + }), + lastSeen: t.expose("lastSeen", { + type: "DateTime", + description: `Last ${observationDescription}`, + }), + }), +}); +export const KeyVersion: DrFedObjectRef = KeyVersionRef; diff --git a/packages/graphql/src/origin.ts b/packages/graphql/src/origin.ts index deb4d55..b58c81e 100644 --- a/packages/graphql/src/origin.ts +++ b/packages/graphql/src/origin.ts @@ -153,3 +153,15 @@ export function canonicalizeAuthority(authority: string): string { const url = `https://${authority}`; return URL.canParse(url) ? canonicalAuthority(new URL(url)) : authority; } + +/** + * Composes the canonical URL of a path on an instance from its stored + * authority, not from the spelling a request used. + * @param rootOrigin The root origin of this deployment, for its scheme. + * @param host The stored authority of the instance, i.e. `instances.host`. + * @param path The absolute path of the resource. + * @returns The canonical URL, e.g. `https://foo-bar.drfed.net/inbox`. + */ +export function instanceUrl(rootOrigin: URL, host: string, path: string): URL { + return new URL(path, `${rootOrigin.protocol}//${host}`); +} diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 7dc7463..0bdac2f 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -19,6 +19,8 @@ import "./instance.ts"; import "./auth/entry.ts"; import "./actor.ts"; import "./object.ts"; +import "./activity-log/entry.ts"; +import "./key.ts"; import builder from "./builder.ts"; builder.queryType({}); diff --git a/packages/models/README.md b/packages/models/README.md index 645949a..fe5f8c8 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -28,3 +28,37 @@ mise run generate:migrate --name your_migration_name The *drizzle/* directory is included in the published npm package so that installed users can run migrations without access to the repository source. + + +Activity log storage +-------------------- + +`activity_logs` stores delivery observations separately from ActivityPub +`activities`. `@drfed/models/activity-log` exposes `recordInbound`, +`receiveInbound`, `recordOutbound`, and `settleOutbound`. `receiveInbound` +marks an `acknowledged` inbound log `received` once a queued inbox listener +ran. An inbound log keeps the received +octets in `body` and the request headers in `headers`; `payload` is the body +parsed as JSON, and is SQL `NULL` when it does not parse or holds U+0000 or an +unpaired surrogate, which jsonb cannot store. `error` and `response_body` +are stored with U+FFFD for each U+0000, which text cannot store, so that a +remote response never keeps a log from being written. `created` is when a +request arrived or a delivery started, and `completed` when DrFed answered the +request or the delivery last changed status. + +`settleOutbound` settles the most recent pending (`queued` or `failed`) +delivery to an inbox and never changes a `sent`, `permanently_failed`, or +`abandoned` one. Each attempt that ends is kept in `activity_log_attempts`, so +a retry never rewrites the outcome of an earlier attempt. + +`activity_log_actors` relates a log to each local actor it concerns, as the +owner of the inbox, as an addressed recipient, or as the sender, with one row +per log and actor. Both record functions insert these rows in the same +transaction as the log. + +`@drfed/models/key` exposes public-JWK validation, RFC 7638/RFC 8037 SHA-256 +thumbprints, and `observeKeyVersion`. `keys` identifies each exact key IRI; +`key_versions` retains immutable public material per fingerprint. Returning to +an earlier key reuses its version. Observation timestamps do not describe +remote rotation times or continuous use. Logs retain referenced versions with +`ON DELETE RESTRICT`, independently of Fedify cache expiry. diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql new file mode 100644 index 0000000..1e21679 --- /dev/null +++ b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/migration.sql @@ -0,0 +1,96 @@ +CREATE TYPE "activity_log_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint +CREATE TYPE "activity_log_status" AS ENUM('received', 'acknowledged', 'unverified', 'rejected', 'queued', 'sent', 'failed', 'permanently_failed', 'abandoned');--> statement-breakpoint +CREATE TYPE "activity_log_verification_mechanism" AS ENUM('http_signature', 'ld_signature', 'object_integrity_proof');--> statement-breakpoint +CREATE TYPE "activity_log_verification_result" AS ENUM('verified', 'invalid_signature', 'key_fetch_error', 'no_signature', 'unattempted', 'unobserved');--> statement-breakpoint +CREATE TABLE "activity_log_actors" ( + "log_id" uuid, + "actor_id" uuid, + "inbox_owner" boolean DEFAULT false NOT NULL, + "addressed" boolean DEFAULT false NOT NULL, + "sender" boolean DEFAULT false NOT NULL, + "via_collection_iri" text, + CONSTRAINT "activity_log_actors_pkey" PRIMARY KEY("log_id","actor_id"), + CONSTRAINT "activity_log_actors_role_check" CHECK ("inbox_owner" OR "addressed" OR "sender") +); +--> statement-breakpoint +CREATE TABLE "activity_log_attempts" ( + "id" uuid PRIMARY KEY, + "log_id" uuid NOT NULL, + "succeeded" boolean NOT NULL, + "status_code" integer, + "response_body" text, + "error" text, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "activity_log_attempts_status_code_check" CHECK ("status_code" IS NULL OR "status_code" BETWEEN 100 AND 599), + CONSTRAINT "activity_log_attempts_error_check" CHECK ("succeeded" = ("error" IS NULL)) +); +--> statement-breakpoint +CREATE TABLE "activity_logs" ( + "id" uuid PRIMARY KEY, + "instance_id" uuid NOT NULL, + "actor_id" uuid, + "direction" "activity_log_direction" NOT NULL, + "status" "activity_log_status" NOT NULL, + "verification_mechanism" "activity_log_verification_mechanism", + "verification_result" "activity_log_verification_result", + "type" text, + "types" text[] DEFAULT '{}'::text[] NOT NULL, + "activity_iri" text, + "object_type" text, + "object_iri" text, + "signed_key_iri" text, + "verification_key_id" uuid, + "remote_actor_iri" text, + "remote_host" text, + "inbox_url" text NOT NULL, + "request_url" text, + "headers" jsonb, + "body" bytea, + "status_code" integer, + "response_body" text, + "error" text, + "payload" jsonb, + "recipient_iris" text[] DEFAULT '{}'::text[] NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "completed" timestamp with time zone, + CONSTRAINT "activity_logs_direction_status_check" CHECK (("direction" = 'inbound' AND "status" IN ('received', 'acknowledged', 'unverified', 'rejected')) OR ("direction" = 'outbound' AND "status" IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))), + CONSTRAINT "activity_logs_completed_check" CHECK (("completed" IS NULL) = ("status" = 'queued')), + CONSTRAINT "activity_logs_status_code_check" CHECK ("status_code" IS NULL OR "status_code" BETWEEN 100 AND 599), + CONSTRAINT "activity_logs_outbound_key_check" CHECK ("direction" <> 'outbound' OR "verification_key_id" IS NULL), + CONSTRAINT "activity_logs_verification_result_check" CHECK (("direction" = 'inbound') = ("verification_result" IS NOT NULL)), + CONSTRAINT "activity_logs_verification_mechanism_check" CHECK ("verification_mechanism" IS NULL OR ("direction" = 'inbound' AND "verification_result" NOT IN ('unattempted', 'unobserved'))), + CONSTRAINT "activity_logs_body_check" CHECK (("direction" = 'inbound') = ("body" IS NOT NULL)) +); +--> statement-breakpoint +CREATE TABLE "key_versions" ( + "id" uuid PRIMARY KEY, + "key_id" uuid NOT NULL, + "public_key" jsonb NOT NULL, + "fingerprint" text NOT NULL, + "first_seen" timestamp with time zone NOT NULL, + "last_seen" timestamp with time zone NOT NULL, + CONSTRAINT "key_versions_key_id_fingerprint_unique" UNIQUE("key_id","fingerprint"), + CONSTRAINT "key_versions_seen_check" CHECK ("last_seen" >= "first_seen"), + CONSTRAINT "key_versions_public_key_check" CHECK (NOT ("public_key" ?| array['d','p','q','dp','dq','qi','oth','k'])) +); +--> statement-breakpoint +CREATE TABLE "keys" ( + "id" uuid PRIMARY KEY, + "iri" text NOT NULL UNIQUE, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE INDEX "activity_log_actor_log_index" ON "activity_log_actors" ("actor_id","log_id" desc);--> statement-breakpoint +CREATE INDEX "activity_log_attempt_log_index" ON "activity_log_attempts" ("log_id","created","id");--> statement-breakpoint +CREATE INDEX "activity_log_instance_created_index" ON "activity_logs" ("instance_id","created" desc,"id" desc);--> statement-breakpoint +CREATE INDEX "activity_log_actor_index" ON "activity_logs" ("actor_id");--> statement-breakpoint +CREATE INDEX "activity_log_verification_key_index" ON "activity_logs" ("verification_key_id");--> statement-breakpoint +CREATE INDEX "activity_log_outbound_index" ON "activity_logs" ("activity_iri","inbox_url") WHERE "direction" = 'outbound';--> statement-breakpoint +CREATE INDEX "key_version_key_first_seen_index" ON "key_versions" ("key_id","first_seen","id");--> statement-breakpoint +ALTER TABLE "activity_log_actors" ADD CONSTRAINT "activity_log_actors_log_id_activity_logs_id_fkey" FOREIGN KEY ("log_id") REFERENCES "activity_logs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activity_log_actors" ADD CONSTRAINT "activity_log_actors_actor_id_actors_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "actors"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activity_log_attempts" ADD CONSTRAINT "activity_log_attempts_log_id_activity_logs_id_fkey" FOREIGN KEY ("log_id") REFERENCES "activity_logs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_instance_id_instances_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_actor_id_actors_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "actors"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_verification_key_id_key_versions_id_fkey" FOREIGN KEY ("verification_key_id") REFERENCES "key_versions"("id") ON DELETE RESTRICT;--> statement-breakpoint +ALTER TABLE "key_versions" ADD CONSTRAINT "key_versions_key_id_keys_id_fkey" FOREIGN KEY ("key_id") REFERENCES "keys"("id") ON DELETE RESTRICT; \ No newline at end of file diff --git a/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json new file mode 100644 index 0000000..009c741 --- /dev/null +++ b/packages/models/drizzle/20260929224246_add_activity_logs_and_keys/snapshot.json @@ -0,0 +1,3445 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "10a7ac3c-ffbe-46a9-8ea9-4f50ee544b12", + "prevIds": [ + "478b925d-8ac8-466c-9804-bb055fdd6489", + "7c4a0afb-efbc-4ae7-b6b5-ebc6daaddb3e" + ], + "ddl": [ + { + "values": ["inbound", "outbound"], + "name": "activity_log_direction", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "received", + "acknowledged", + "unverified", + "rejected", + "queued", + "sent", + "failed", + "permanently_failed", + "abandoned" + ], + "name": "activity_log_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["http_signature", "ld_signature", "object_integrity_proof"], + "name": "activity_log_verification_mechanism", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "verified", + "invalid_signature", + "key_fetch_error", + "no_signature", + "unattempted", + "unobserved" + ], + "name": "activity_log_verification_result", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Create"], + "name": "activity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Application", "Group", "Organization", "Person", "Service"], + "name": "actor_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["to", "cc", "bto", "bcc", "audience"], + "name": "addressing_property", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["followers", "following", "featured", "outbox"], + "name": "collection_role", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Collection", "OrderedCollection"], + "name": "collection_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["Article", "Note"], + "name": "object_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["actor", "object", "activity", "collection", "unknown"], + "name": "resource_kind", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_log_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_log_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "activity_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actor_collection_references", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "addressing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "key_versions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "local_instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "objects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "resources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "max_instances", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "activity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "objectId", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "log_id", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "inbox_owner", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "addressed", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sender", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "via_collection_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_log_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "log_id", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "succeeded", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status_code", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_body", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instance_id", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "activity_log_direction", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "direction", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "activity_log_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "activity_log_verification_mechanism", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_mechanism", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "activity_log_verification_result", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_result", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "types", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "object_type", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "object_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signed_key_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_key_id", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_actor_iri", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_host", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inbox_url", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "request_url", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headers", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status_code", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_body", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipient_iris", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completed", + "entityType": "columns", + "schema": "public", + "table": "activity_logs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "collection_role", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "actor_collection_references" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "actor_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inboxUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profileUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headerUrl", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bioHtml", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "automaticallyApprovesFollowers", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fieldHtmls", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspendedUntil", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successorId", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followingCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followersCount", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "addressing_property", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "property", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "addressing" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collectionId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "observed", + "entityType": "columns", + "schema": "public", + "table": "collection_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "collection_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerActorId", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalItems", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localId", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(259)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nodeInfoUrl", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "softwareVersion", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key_id", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen", + "entityType": "columns", + "schema": "public", + "table": "key_versions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "header", + "entityType": "columns", + "schema": "public", + "table": "local_actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "varchar(63)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "maxActors", + "entityType": "columns", + "schema": "public", + "table": "local_instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "char(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "object_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentHtml", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted", + "entityType": "columns", + "schema": "public", + "table": "objects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "resource_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "resources" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "objectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_object_published_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"log_id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_actor_log_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_log_actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "log_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_attempt_log_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instance_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"created\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_instance_created_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_actor_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "verification_key_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_verification_key_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_iri", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "inbox_url", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"direction\" = 'outbound'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "activity_log_outbound_index", + "entityType": "indexes", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_collection_reference_collection_index", + "entityType": "indexes", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actor_instance_index", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "property", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "addressing_target_property_index", + "entityType": "indexes", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "collectionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "collection_item_position_index", + "entityType": "indexes", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "key_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "first_seen", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "key_version_key_first_seen_index", + "entityType": "indexes", + "schema": "public", + "table": "key_versions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"published\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "\"id\" desc", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "object_actor_published_index", + "entityType": "indexes", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["objectId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activities_objectId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": false, + "columns": ["log_id"], + "schemaTo": "public", + "tableTo": "activity_logs", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_log_actors_log_id_activity_logs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_log_actors" + }, + { + "nameExplicit": false, + "columns": ["actor_id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_log_actors_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_log_actors" + }, + { + "nameExplicit": false, + "columns": ["log_id"], + "schemaTo": "public", + "tableTo": "activity_logs", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_log_attempts_log_id_activity_logs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "nameExplicit": false, + "columns": ["instance_id"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "activity_logs_instance_id_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": false, + "columns": ["actor_id"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "activity_logs_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": false, + "columns": ["verification_key_id"], + "schemaTo": "public", + "tableTo": "key_versions", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "activity_logs_verification_key_id_key_versions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "activity_logs" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actor_collection_references_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_localId_local_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "actors_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["successorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "actors_successorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": false, + "columns": ["sourceId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "addressing_sourceId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["targetId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "addressing_targetId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": false, + "columns": ["collectionId"], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_collectionId_collections_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["itemId"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_items_itemId_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_items" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["ownerActorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collections_ownerActorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "collections" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "schemaTo": "public", + "tableTo": "local_instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "instances_localId_local_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instances" + }, + { + "nameExplicit": false, + "columns": ["key_id"], + "schemaTo": "public", + "tableTo": "keys", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "key_versions_key_id_keys_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "key_versions" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_challenges" + }, + { + "nameExplicit": false, + "columns": ["id"], + "schemaTo": "public", + "tableTo": "resources", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_id_resources_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["actorId"], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "objects_actorId_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "objects" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["log_id", "actor_id"], + "nameExplicit": false, + "name": "activity_log_actors_pkey", + "entityType": "pks", + "schema": "public", + "table": "activity_log_actors" + }, + { + "columns": ["actorId", "role"], + "nameExplicit": false, + "name": "actor_collection_references_pkey", + "entityType": "pks", + "schema": "public", + "table": "actor_collection_references" + }, + { + "columns": ["collectionId", "itemId"], + "nameExplicit": false, + "name": "collection_items_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_items" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "activities_pkey", + "schema": "public", + "table": "activities", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "activity_log_attempts_pkey", + "schema": "public", + "table": "activity_log_attempts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "activity_logs_pkey", + "schema": "public", + "table": "activity_logs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "addressing_pkey", + "schema": "public", + "table": "addressing", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "key_versions_pkey", + "schema": "public", + "table": "key_versions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "keys_pkey", + "schema": "public", + "table": "keys", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_actors_pkey", + "schema": "public", + "table": "local_actors", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "local_instances_pkey", + "schema": "public", + "table": "local_instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_challenges", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "objects_pkey", + "schema": "public", + "table": "objects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "resources_pkey", + "schema": "public", + "table": "resources", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": ["username", "instanceId"], + "nullsNotDistinct": false, + "name": "username_key", + "entityType": "uniques", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": ["sourceId", "property", "position"], + "nullsNotDistinct": false, + "name": "addressing_source_property_position_key", + "entityType": "uniques", + "schema": "public", + "table": "addressing" + }, + { + "nameExplicit": true, + "columns": ["key_id", "fingerprint"], + "nullsNotDistinct": false, + "name": "key_versions_key_id_fingerprint_unique", + "entityType": "uniques", + "schema": "public", + "table": "key_versions" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["localId"], + "nullsNotDistinct": false, + "name": "actors_localId_key", + "schema": "public", + "table": "actors", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["host"], + "nullsNotDistinct": false, + "name": "instances_host_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "keys_iri_key", + "schema": "public", + "table": "keys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "local_instances_slug_key", + "schema": "public", + "table": "local_instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["iri"], + "nullsNotDistinct": false, + "name": "resources_iri_key", + "schema": "public", + "table": "resources", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"max_instances\" >= 0", + "name": "accounts_max_instances_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"inbox_owner\" OR \"addressed\" OR \"sender\"", + "name": "activity_log_actors_role_check", + "entityType": "checks", + "schema": "public", + "table": "activity_log_actors" + }, + { + "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599", + "name": "activity_log_attempts_status_code_check", + "entityType": "checks", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "value": "\"succeeded\" = (\"error\" IS NULL)", + "name": "activity_log_attempts_error_check", + "entityType": "checks", + "schema": "public", + "table": "activity_log_attempts" + }, + { + "value": "(\"direction\" = 'inbound' AND \"status\" IN ('received', 'acknowledged', 'unverified', 'rejected')) OR (\"direction\" = 'outbound' AND \"status\" IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))", + "name": "activity_logs_direction_status_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "(\"completed\" IS NULL) = (\"status\" = 'queued')", + "name": "activity_logs_completed_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "\"status_code\" IS NULL OR \"status_code\" BETWEEN 100 AND 599", + "name": "activity_logs_status_code_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "\"direction\" <> 'outbound' OR \"verification_key_id\" IS NULL", + "name": "activity_logs_outbound_key_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "(\"direction\" = 'inbound') = (\"verification_result\" IS NOT NULL)", + "name": "activity_logs_verification_result_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "\"verification_mechanism\" IS NULL OR (\"direction\" = 'inbound' AND \"verification_result\" NOT IN ('unattempted', 'unobserved'))", + "name": "activity_logs_verification_mechanism_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "(\"direction\" = 'inbound') = (\"body\" IS NOT NULL)", + "name": "activity_logs_body_check", + "entityType": "checks", + "schema": "public", + "table": "activity_logs" + }, + { + "value": "\"username\" NOT LIKE '%@%'", + "name": "actors_username_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\n \"suspendedUntil\" IS NULL OR (\n \"suspended\" IS NOT NULL AND\n \"suspendedUntil\" > \"suspended\"\n )\n ", + "name": "actors_suspended_check", + "entityType": "checks", + "schema": "public", + "table": "actors" + }, + { + "value": "\"last_seen\" >= \"first_seen\"", + "name": "key_versions_seen_check", + "entityType": "checks", + "schema": "public", + "table": "key_versions" + }, + { + "value": "NOT (\"public_key\" ?| array['d','p','q','dp','dq','qi','oth','k'])", + "name": "key_versions_public_key_check", + "entityType": "checks", + "schema": "public", + "table": "key_versions" + }, + { + "value": "\"slug\" ~ '^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$'\n AND (\"slug\" !~ '^..--' OR \"slug\" ~ '^xn--')", + "name": "local_instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "\"maxActors\" > 0", + "name": "instances_max_actors_check", + "entityType": "checks", + "schema": "public", + "table": "local_instances" + }, + { + "value": "trim(both from \"contentHtml\") <> ''", + "name": "objects_content_html_check", + "entityType": "checks", + "schema": "public", + "table": "objects" + } + ], + "renames": [] +} diff --git a/packages/models/package.json b/packages/models/package.json index 44e366b..9d3cefe 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -81,6 +81,14 @@ "./slug": { "types": "./dist/slug.d.mts", "default": "./dist/slug.mjs" + }, + "./key": { + "types": "./dist/key.d.mts", + "default": "./dist/key.mjs" + }, + "./activity-log": { + "types": "./dist/activity-log.d.mts", + "default": "./dist/activity-log.mjs" } }, "files": [ @@ -99,7 +107,9 @@ "src/uuid.ts", "src/login.ts", "src/resource.ts", - "src/slug.ts" + "src/slug.ts", + "src/key.ts", + "src/activity-log.ts" ], "dts": { "sourcemap": true, diff --git a/packages/models/src/activity-log.test.ts b/packages/models/src/activity-log.test.ts new file mode 100644 index 0000000..56f88b2 --- /dev/null +++ b/packages/models/src/activity-log.test.ts @@ -0,0 +1,435 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable id-length max-statements no-await-in-loop + +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { migrate, relations, schema } from "@drfed/models"; +import { + type OutboundSettlement, + inboundActorRows, + receiveInbound, + recordInbound, + recordOutbound, + settleOutbound, +} from "@drfed/models/activity-log"; +import { observeKeyVersion } from "@drfed/models/key"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { and, eq, isNull } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; + +it("enforces log constraints, key retention and outbound state transitions", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const instanceId = uuidV7(); + await db + .insert(schema.instances) + .values({ id: instanceId, host: "local.example" }); + const version = await observeKeyVersion(db, { + iri: "https://remote.example/key", + publicKey: { kty: "RSA", e: "AQAB", n: "test" }, + }); + const entry = { + instanceId, + inboxUrl: "https://local.example/inbox", + payload: { type: "Create", bcc: ["private"] }, + }; + const body = new TextEncoder().encode('{"type":"Create","type":"Follow"}'); + const now = Temporal.Now.instant(); + const observed = { + verificationResult: "invalid_signature", + body, + created: now, + completed: now, + } as const; + const inbound = await recordInbound(db, { + ...entry, + ...observed, + status: "unverified", + verificationKeyId: version.id, + }); + assert.deepEqual(inbound.payload, entry.payload); + assert.deepEqual(new Uint8Array(inbound.body!), body); + const presetId = uuidV7(); + const acknowledged = await recordInbound(db, { + ...entry, + ...observed, + id: presetId, + status: "acknowledged", + }); + assert.equal(acknowledged.id, presetId); + assert.equal(await receiveInbound(db, presetId), true); + assert.equal( + (await db.query.activityLogs.findFirst({ where: { id: presetId } })) + ?.status, + "received", + ); + // Only an acknowledged inbound log is received later. + assert.equal(await receiveInbound(db, presetId), false); + assert.equal(await receiveInbound(db, inbound.id), false); + const unparsed = await recordInbound(db, { + ...entry, + ...observed, + payload: undefined, + status: "unverified", + error: "threw\u0000", + responseBody: "\u0000", + }); + // Nor does text hold U+0000, which an error or a response may carry. + assert.deepEqual( + [unparsed.error, unparsed.responseBody], + ["threw\ufffd", "\ufffd"], + ); + assert.equal( + await db.$count( + schema.activityLogs, + and( + eq(schema.activityLogs.id, unparsed.id), + isNull(schema.activityLogs.payload), + ), + ), + 1, + ); + const inboundRow = { + ...entry, + ...observed, + id: uuidV7(), + direction: "inbound", + status: "received", + body: Buffer.from(body), + } as const; + await assert.rejects( + db + .insert(schema.activityLogs) + .values({ ...inboundRow, verificationResult: null }), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ ...inboundRow, body: null }), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ ...inboundRow, completed: null }), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ + ...entry, + id: uuidV7(), + direction: "outbound", + status: "queued", + verificationResult: "verified", + }), + ); + await assert.rejects( + db + .delete(schema.keyVersions) + .where(eq(schema.keyVersions.id, version.id)), + ); + await assert.rejects( + db.delete(schema.keys).where(eq(schema.keys.id, version.keyId)), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ ...inboundRow, status: "sent" }), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ + ...entry, + id: uuidV7(), + direction: "outbound", + status: "queued", + verificationKeyId: version.id, + }), + ); + await assert.rejects( + db.insert(schema.activityLogs).values({ ...inboundRow, statusCode: 600 }), + ); + const outgoing = { + ...entry, + activityIri: "https://local.example/activity/1", + }; + const row = await recordOutbound(db, outgoing); + const current = (id = row.id) => + db.query.activityLogs.findFirst({ + where: { id }, + with: { attempts: { orderBy: { created: "asc", id: "asc" } } }, + }); + const attempts = async (id = row.id) => + ((await current(id))?.attempts ?? []).map((attempt) => [ + attempt.succeeded, + attempt.statusCode, + attempt.error, + attempt.responseBody, + ]); + assert.equal((await current())?.status, "queued"); + assert.equal((await current())?.completed, null); + await assert.rejects( + db + .update(schema.activityLogs) + .set({ completed: now }) + .where(eq(schema.activityLogs.id, row.id)), + ); + assert.equal( + await settleOutbound(db, { + ...outgoing, + status: "failed", + statusCode: 503, + error: "Unavailable", + responseBody: "Later", + attempted: true, + }), + true, + ); + assert.equal((await current())?.statusCode, 503); + await settleOutbound(db, { ...outgoing, status: "sent", attempted: true }); + const sent = await current(); + assert.equal(sent?.status, "sent"); + assert.deepEqual( + [sent?.statusCode, sent?.error, sent?.responseBody], + [null, null, null], + ); + assert.ok(sent?.completed != null); + assert.deepEqual(await attempts(), [ + [false, 503, "Unavailable", "Later"], + [true, null, null, null], + ]); + assert.equal( + await settleOutbound(db, { + ...outgoing, + status: "permanently_failed", + statusCode: 410, + error: "Gone", + attempted: true, + }), + false, + ); + assert.equal((await current())?.status, "sent"); + assert.equal((await attempts()).length, 2); + const [attempt] = (await current())?.attempts ?? []; + await assert.rejects( + db.insert(schema.activityLogAttempts).values({ + ...attempt!, + id: uuidV7(), + succeeded: true, + error: "Unavailable", + }), + ); + await assert.rejects( + db + .insert(schema.activityLogAttempts) + .values({ ...attempt!, id: uuidV7(), statusCode: 600 }), + ); + const final = async ( + activityIri: string, + settlement: Omit, + ) => { + const log = await recordOutbound(db, { ...outgoing, activityIri }); + await settleOutbound(db, { ...outgoing, ...settlement, activityIri }); + return log.id; + }; + const abandoned = await final("https://local.example/activity/2", { + status: "abandoned", + statusCode: 503, + error: "Unavailable", + attempted: true, + }); + assert.equal((await current(abandoned))?.status, "abandoned"); + assert.deepEqual(await attempts(abandoned), [ + [false, 503, "Unavailable", null], + ]); + const dropped = await final("https://local.example/activity/3", { + status: "permanently_failed", + error: "Circuit breaker held activity expired.", + attempted: false, + }); + assert.equal((await current(dropped))?.status, "permanently_failed"); + assert.ok((await current(dropped))?.completed != null); + assert.deepEqual(await attempts(dropped), []); + const unstorable = await final("https://local.example/activity/5", { + status: "failed", + statusCode: 500, + error: "Failed:\nerror\u0000details", + responseBody: "error\u0000details", + attempted: true, + }); + assert.deepEqual( + [ + (await current(unstorable))?.status, + (await current(unstorable))?.responseBody, + ], + ["failed", "error\ufffddetails"], + ); + assert.deepEqual(await attempts(unstorable), [ + [false, 500, "Failed:\nerror\ufffddetails", "error\ufffddetails"], + ]); + const retried = { + ...outgoing, + activityIri: "https://local.example/activity/4", + }; + const deliveries = []; + for (const [statusCode, error] of [ + [503, "First"], + [502, "Second"], + ] as const) { + deliveries.push(await recordOutbound(db, retried)); + await settleOutbound(db, { + ...retried, + status: "failed", + statusCode, + error, + attempted: true, + }); + } + assert.deepEqual( + ( + await db.query.activityLogs.findMany({ + where: { activityIri: retried.activityIri }, + orderBy: { id: "asc" }, + }) + ).map((log) => [log.id, log.status, log.statusCode, log.error]), + [ + [deliveries[0]!.id, "failed", 503, "First"], + [deliveries[1]!.id, "failed", 502, "Second"], + ], + ); + assert.equal( + await settleOutbound(db, { + ...retried, + id: deliveries[0]!.id, + status: "sent", + attempted: true, + }), + true, + ); + assert.equal((await current(deliveries[1]!.id))?.status, "failed"); + await db + .delete(schema.instances) + .where(eq(schema.instances.id, instanceId)); + assert.equal(await db.$count(schema.activityLogs), 0); + assert.equal(await db.$count(schema.activityLogAttempts), 0); + await db + .delete(schema.keyVersions) + .where(eq(schema.keyVersions.id, version.id)); + } finally { + await client.close(); + } +}); + +it("keeps one row per log and actor and requires a role", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const instanceId = uuidV7(); + const actorId = uuidV7(); + const otherId = uuidV7(); + await db + .insert(schema.instances) + .values({ id: instanceId, host: "local.example" }); + for (const [id, username] of [ + [actorId, "alice"], + [otherId, "bob"], + ] as const) { + const iri = `https://local.example/users/${id}`; + await db.insert(schema.resources).values({ id, iri, kind: "actor" }); + await db.insert(schema.actors).values({ + id, + type: "Person", + username, + instanceId, + inboxUrl: `${iri}/inbox`, + }); + } + const collection = "https://local.example/collections/1"; + assert.deepEqual( + inboundActorRows({ + actorId, + addressed: [ + { actorId, viaCollectionIri: collection }, + { actorId }, + { actorId: otherId, viaCollectionIri: collection }, + ], + }), + [ + { actorId, inboxOwner: true, addressed: true, viaCollectionIri: null }, + { actorId: otherId, addressed: true, viaCollectionIri: collection }, + ], + ); + const log = await recordInbound(db, { + instanceId, + actorId, + inboxUrl: `https://local.example/users/${actorId}/inbox`, + status: "received", + verificationResult: "verified", + body: new TextEncoder().encode("{}"), + payload: {}, + addressed: [{ actorId }, { actorId: otherId }], + created: Temporal.Now.instant(), + completed: Temporal.Now.instant(), + }); + const sent = await recordOutbound(db, { + instanceId, + actorId, + inboxUrl: "https://remote.example/inbox", + activityIri: "https://local.example/activity/1", + payload: {}, + recipientIris: ["https://remote.example/a", "https://remote.example/b"], + }); + assert.deepEqual(sent.recipientIris, [ + "https://remote.example/a", + "https://remote.example/b", + ]); + const links = await db.query.activityLogActors.findMany({ + orderBy: { logId: "asc", actorId: "asc" }, + }); + assert.deepEqual( + links.map((link) => [ + link.logId, + link.actorId, + link.inboxOwner, + link.addressed, + link.sender, + ]), + [ + [log.id, actorId, true, true, false], + [log.id, otherId, false, true, false], + [sent.id, actorId, false, false, true], + ], + ); + await assert.rejects( + db + .insert(schema.activityLogActors) + .values({ logId: log.id, actorId, sender: true }), + ); + await assert.rejects( + db + .insert(schema.activityLogActors) + .values({ logId: sent.id, actorId: otherId }), + ); + const found = await db.query.actors.findFirst({ + where: { id: actorId }, + with: { activityLogs: { orderBy: { created: "desc", id: "desc" } } }, + }); + assert.deepEqual( + found?.activityLogs.map((row) => row.id), + [sent.id, log.id], + ); + } finally { + await client.close(); + } +}); diff --git a/packages/models/src/activity-log.ts b/packages/models/src/activity-log.ts new file mode 100644 index 0000000..72d317a --- /dev/null +++ b/packages/models/src/activity-log.ts @@ -0,0 +1,287 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { and, desc, eq, inArray, sql } from "drizzle-orm"; + +import type { Database } from "./db.ts"; +import { + type ActivityLog, + type NewActivityLog, + type NewActivityLogActor, + activityLogActors, + activityLogAttempts, + activityLogs, +} from "./schema.ts"; +import { type Uuid, uuidV7 } from "./uuid.ts"; + +type LogEntry = Omit; + +export interface AddressedActor { + readonly actorId: Uuid; + readonly viaCollectionIri?: string | null; +} + +/** + * `payload` is `undefined` for an unparsable body and `null` for JSON `null`; + * it is stored as SQL `NULL` when jsonb cannot hold it, and `body` keeps it. + */ +export type InboundLogEntry = Omit< + LogEntry, + "body" | "verificationResult" | "recipientIris" +> & { + /** Chosen in advance when something must name the log before it exists. */ + readonly id?: Uuid; + readonly status: "received" | "acknowledged" | "unverified" | "rejected"; + readonly verificationResult: NonNullable; + readonly body: Uint8Array; + readonly addressed?: readonly AddressedActor[]; + /** When the request arrived, before verification and handling. */ + readonly created: Temporal.Instant; + /** When DrFed answered the request. */ + readonly completed: Temporal.Instant; +}; + +/** `payload` is the document before signing, without `bto` and `bcc`. */ +export type OutboundLogEntry = Omit< + LogEntry, + | "verificationKeyId" + | "verificationMechanism" + | "verificationResult" + | "body" + | "headers" + | "requestUrl" + | "completed" +> & { + readonly activityIri: string; +}; + +type ActorRow = Omit; + +/** + * Merge the inbox owner and addressed actors into one row per actor. + * @returns The actor rows of an inbound log. + */ +export function inboundActorRows(entry: { + readonly actorId?: Uuid | null | undefined; + readonly addressed?: readonly AddressedActor[] | undefined; +}): ActorRow[] { + const rows = new Map(); + if (entry.actorId != null) { + rows.set(entry.actorId, { actorId: entry.actorId, inboxOwner: true }); + } + for (const { actorId, viaCollectionIri } of entry.addressed ?? []) { + const row = rows.get(actorId); + rows.set(actorId, { + ...row, + actorId, + addressed: true, + viaCollectionIri: + row?.addressed === true && row.viaCollectionIri == null + ? null + : (viaCollectionIri ?? null), + }); + } + return [...rows.values()]; +} + +/** + * PostgreSQL's jsonb holds neither U+0000 nor an unpaired surrogate, in keys + * or in values. + * @returns Whether the JSON value can be stored as jsonb. + */ +function storableJson(value: unknown): boolean { + if (typeof value === "string") { + return !value.includes("\0") && value.isWellFormed(); + } + return ( + typeof value !== "object" || + value == null || + Object.entries(value).every( + ([key, item]) => storableJson(key) && storableJson(item), + ) + ); +} + +/** + * Keep a payload jsonb cannot hold out of the log, rather than the log. + * @returns The value to store, SQL `NULL` for such a payload. + */ +const jsonb = (payload: unknown) => + payload === null + ? sql`'null'::jsonb` + : storableJson(payload) + ? payload + : null; + +/** + * PostgreSQL's text holds no U+0000, which a remote response or an error + * quoting one may carry; keep the rest of it rather than lose the log. + * @returns The text with each U+0000 replaced by U+FFFD. + */ +const text = (value: string | null | undefined): string | null => + value?.replaceAll("\0", "\ufffd") ?? null; + +async function insertLog( + db: Database, + log: NewActivityLog, + actors: readonly ActorRow[], +): Promise { + return await db.transaction(async (tx) => { + const [row] = await tx + .insert(activityLogs) + .values({ + ...log, + error: text(log.error), + responseBody: text(log.responseBody), + }) + .returning(); + if (row == null) throw new Error("Missing activity log after insertion."); + if (actors.length > 0) { + await tx + .insert(activityLogActors) + .values(actors.map((actor) => ({ ...actor, logId: row.id }))); + } + return row; + }); +} + +/** + * Persist one inbound observation with its actor rows in one transaction. + * @returns The inserted inbound log. + */ +export async function recordInbound( + db: Database, + { addressed, body, id = uuidV7(), ...entry }: InboundLogEntry, +): Promise { + return await insertLog( + db, + { + ...entry, + body: Buffer.from(body), + payload: jsonb(entry.payload), + id, + direction: "inbound", + }, + inboundActorRows({ actorId: entry.actorId, addressed }), + ); +} + +/** + * Record that an inbox listener ran for an inbound delivery answered before it + * did, as a queued one is. + * @returns Whether an acknowledged inbound log was found. + */ +export async function receiveInbound(db: Database, id: Uuid): Promise { + const rows = await db + .update(activityLogs) + .set({ status: "received" }) + .where( + and( + eq(activityLogs.id, id), + eq(activityLogs.direction, "inbound"), + eq(activityLogs.status, "acknowledged"), + ), + ) + .returning({ id: activityLogs.id }); + return rows.length > 0; +} + +/** + * Start a delivery observation. The payload is the document before signing. + * @returns The inserted queued outbound log. + */ +export async function recordOutbound( + db: Database, + entry: OutboundLogEntry, +): Promise { + return await insertLog( + db, + { + ...entry, + // The application clock, as inbound logs use, so both directions sort together. + created: entry.created ?? Temporal.Now.instant(), + payload: jsonb(entry.payload), + id: uuidV7(), + direction: "outbound", + status: "queued", + }, + entry.actorId == null ? [] : [{ actorId: entry.actorId, sender: true }], + ); +} + +/** What a settled delivery shows; null fields for a successful attempt. */ +export interface OutboundSettlement { + readonly activityIri: string; + readonly inboxUrl: string; + readonly status: "sent" | "failed" | "permanently_failed" | "abandoned"; + readonly statusCode?: number | null; + readonly error?: string | null; + readonly responseBody?: string | null; + /** Whether an attempt ended with this result, rather than none being made. */ + readonly attempted: boolean; + /** Restrict a synchronous delivery to the row started by that invocation. */ + readonly id?: Uuid; +} + +/** + * Settle the most recent pending (`queued` or `failed`) delivery to an inbox, + * keeping each ended attempt. `sent`, `permanently_failed` and `abandoned` + * deliveries are never changed again. + * @returns Whether a pending delivery was found. + */ +export async function settleOutbound( + db: Database, + entry: OutboundSettlement, +): Promise { + const summary = { + statusCode: entry.statusCode ?? null, + error: text(entry.error), + responseBody: text(entry.responseBody), + }; + return await db.transaction(async (tx) => { + const [log] = await tx + .select({ id: activityLogs.id }) + .from(activityLogs) + .where( + and( + eq(activityLogs.direction, "outbound"), + eq(activityLogs.activityIri, entry.activityIri), + eq(activityLogs.inboxUrl, entry.inboxUrl), + inArray(activityLogs.status, ["queued", "failed"]), + entry.id == null ? undefined : eq(activityLogs.id, entry.id), + ), + ) + .orderBy(desc(activityLogs.created), desc(activityLogs.id)) + .limit(1) + .for("update"); + if (log == null) return false; + const completed = Temporal.Now.instant(); + if (entry.attempted) { + await tx.insert(activityLogAttempts).values({ + ...summary, + id: uuidV7(), + logId: log.id, + succeeded: entry.status === "sent", + created: completed, + }); + } + await tx + .update(activityLogs) + .set({ ...summary, status: entry.status, completed }) + .where(eq(activityLogs.id, log.id)); + return true; + }); +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index a00025b..d5fb44b 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -20,3 +20,5 @@ export { relations } from "./relations.ts"; export * as schema from "./schema.ts"; export * from "./login.ts"; export * from "./resource.ts"; +export * from "./key.ts"; +export * from "./activity-log.ts"; diff --git a/packages/models/src/key.test.ts b/packages/models/src/key.test.ts new file mode 100644 index 0000000..c4a3743 --- /dev/null +++ b/packages/models/src/key.test.ts @@ -0,0 +1,117 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// oxlint-disable id-length max-statements + +import assert from "node:assert/strict"; +import { it } from "node:test"; + +import { migrate, relations, schema } from "@drfed/models"; +import { + jwkThumbprint, + observeKeyVersion, + toPublicJwk, +} from "@drfed/models/key"; +import { uuidV7 } from "@drfed/models/uuid"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; + +const ed = { + kty: "OKP", + crv: "Ed25519", + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", +}; + +it("matches RFC 7638 and RFC 8037 thumbprint vectors", async () => { + assert.equal( + await jwkThumbprint({ + kty: "RSA", + e: "AQAB", + alg: "RS256", + kid: "2011-04-29", + n: + "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt" + + "VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6" + + "4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD" + + "W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9" + + "1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH" + + "aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + }), + "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", + ); + assert.equal( + await jwkThumbprint(ed), + "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + ); + assert.equal( + await jwkThumbprint({ ...ed, ext: true, key_ops: ["verify"] }), + await jwkThumbprint(ed), + ); + assert.deepEqual(toPublicJwk({ ...ed, ext: true, key_ops: ["verify"] }), ed); + assert.throws(() => toPublicJwk({ ...ed, d: "private" }), TypeError); + await assert.rejects(jwkThumbprint({ kty: "EC" }), TypeError); +}); + +it("reuses A after A, B, A without mutating material or firstSeen", async () => { + const client = new PGlite(); + try { + await migrate({ credentials: { driver: "pglite", client } }); + const db = drizzle({ client, schema, relations }); + const iri = "https://remote.example/key"; + const first = Temporal.Instant.from("2026-01-01T00:00:00Z"); + const last = first.add({ hours: 2 }); + const a = await observeKeyVersion(db, { + iri, + publicKey: ed, + observed: first, + }); + const b = await observeKeyVersion(db, { + iri, + publicKey: { ...ed, x: "different" }, + observed: first.add({ hours: 1 }), + }); + const again = await observeKeyVersion(db, { + iri, + publicKey: { ...ed, kid: "ignored-change" }, + observed: last, + }); + assert.notEqual(a.id, b.id); + assert.equal(again.id, a.id); + assert.equal(again.firstSeen.toString(), first.toString()); + assert.equal(again.lastSeen.toString(), last.toString()); + assert.deepEqual(again.publicKey, ed); + assert.equal(await db.$count(schema.keys), 1); + assert.equal(await db.$count(schema.keyVersions), 2); + const earlier = await observeKeyVersion(db, { + iri, + publicKey: ed, + observed: first, + }); + assert.equal(earlier.lastSeen.toString(), last.toString()); + await assert.rejects( + db.insert(schema.keyVersions).values({ + id: uuidV7(), + keyId: a.keyId, + fingerprint: "private", + publicKey: { ...ed, d: "private" }, + firstSeen: first, + lastSeen: last, + }), + ); + } finally { + await client.close(); + } +}); diff --git a/packages/models/src/key.ts b/packages/models/src/key.ts new file mode 100644 index 0000000..d4dc83b --- /dev/null +++ b/packages/models/src/key.ts @@ -0,0 +1,137 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { eq, sql } from "drizzle-orm"; + +import type { Database } from "./db.ts"; +import { type KeyVersion, keyVersions, keys } from "./schema.ts"; +import { uuidV7 } from "./uuid.ts"; + +/** Web Crypto's JWK export type, including optional JOSE metadata. */ +export type PublicJwk = Exclude< + Awaited>, + ArrayBuffer +> & { kid?: string; use?: string }; + +const privateParameters = [ + "d", + "p", + "q", + "dp", + "dq", + "qi", + "oth", + "k", +] as const; + +/** + * Reject private/symmetric key material and remove mutable Web Crypto metadata. + * @returns The public JWK without key_ops and ext. + */ +export function toPublicJwk(jwk: PublicJwk): PublicJwk { + if (privateParameters.some((name) => name in jwk)) { + throw new TypeError("Expected an asymmetric public JWK."); + } + const { key_ops: _operations, ext: _extractable, ...publicKey } = jwk; + return publicKey; +} + +/** + * RFC 7638 / RFC 8037 required members in lexicographic order. + * @returns Canonical JSON for the thumbprint hash. + */ +export function thumbprintInput(jwk: PublicJwk): string { + const key = toPublicJwk(jwk); + const required = + key.kty === "RSA" + ? // oxlint-disable id-length + { e: key.e, kty: key.kty, n: key.n } + : key.kty === "OKP" && key.crv === "Ed25519" + ? { crv: key.crv, kty: key.kty, x: key.x } + : null; + // oxlint-enable id-length + if ( + required == null || + Object.values(required).some( + (value) => typeof value !== "string" || value.length === 0, + ) + ) { + throw new TypeError("Unsupported or incomplete public JWK."); + } + return JSON.stringify(required); +} + +/** + * SHA-256 JWK thumbprint, base64url without padding. + * @returns The public key fingerprint. + */ +export async function jwkThumbprint(jwk: PublicJwk): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(thumbprintInput(jwk)), + ); + return new Uint8Array(digest).toBase64({ + alphabet: "base64url", + omitPadding: true, + }); +} + +/** + * Observe immutable public key material, reusing a version if a key returns to it. + * @returns The observed, immutable version with its updated lastSeen. + */ +export async function observeKeyVersion( + db: Database, + entry: { + readonly iri: string; + readonly publicKey: PublicJwk; + readonly observed?: Temporal.Instant; + }, +): Promise { + const publicKey = toPublicJwk(entry.publicKey); + const fingerprint = await jwkThumbprint(publicKey); + return await db.transaction(async (tx) => { + await tx + .insert(keys) + .values({ id: uuidV7(), iri: entry.iri }) + .onConflictDoNothing({ target: keys.iri }); + const [key] = await tx.select().from(keys).where(eq(keys.iri, entry.iri)); + if (key == null) throw new Error("Missing logical key after insertion."); + // Take the observation time after serializing competing key insertions. + const observed = entry.observed ?? Temporal.Now.instant(); + const [version] = await tx + .insert(keyVersions) + .values({ + id: uuidV7(), + keyId: key.id, + publicKey, + fingerprint, + firstSeen: observed, + lastSeen: observed, + }) + .onConflictDoUpdate({ + target: [keyVersions.keyId, keyVersions.fingerprint], + set: { + lastSeen: sql`greatest(${keyVersions.lastSeen}, excluded.last_seen)`, + }, + }) + .returning(); + if (version == null) { + throw new Error("Missing key version after insertion."); + } + return version; + }); +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index c57398d..04a586b 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -19,6 +19,63 @@ import { defineRelations } from "drizzle-orm"; import * as schema from "./schema.ts"; export const relations = defineRelations(schema, (r) => ({ + keys: { + versions: r.many.keyVersions({ from: r.keys.id, to: r.keyVersions.keyId }), + }, + keyVersions: { + key: r.one.keys({ + from: r.keyVersions.keyId, + to: r.keys.id, + optional: false, + }), + activityLogs: r.many.activityLogs({ + from: r.keyVersions.id, + to: r.activityLogs.verificationKeyId, + }), + }, + activityLogs: { + instance: r.one.instances({ + from: r.activityLogs.instanceId, + to: r.instances.id, + optional: false, + }), + actor: r.one.actors({ + from: r.activityLogs.actorId, + to: r.actors.id, + where: { deleted: { isNull: true } }, + }), + actorLinks: r.many.activityLogActors({ + from: r.activityLogs.id, + to: r.activityLogActors.logId, + }), + verificationKey: r.one.keyVersions({ + from: r.activityLogs.verificationKeyId, + to: r.keyVersions.id, + }), + attempts: r.many.activityLogAttempts({ + from: r.activityLogs.id, + to: r.activityLogAttempts.logId, + }), + }, + activityLogAttempts: { + log: r.one.activityLogs({ + from: r.activityLogAttempts.logId, + to: r.activityLogs.id, + optional: false, + }), + }, + activityLogActors: { + log: r.one.activityLogs({ + from: r.activityLogActors.logId, + to: r.activityLogs.id, + optional: false, + }), + actor: r.one.actors({ + from: r.activityLogActors.actorId, + to: r.actors.id, + optional: false, + }), + }, accounts: { instances: r.many.instances({ from: r.accounts.id.through(r.instanceMembers.accountId), @@ -56,6 +113,10 @@ export const relations = defineRelations(schema, (r) => ({ }), }, instances: { + activityLogs: r.many.activityLogs({ + from: r.instances.id, + to: r.activityLogs.instanceId, + }), members: r.many.accounts({ from: r.instances.id.through(r.instanceMembers.instanceId), to: r.accounts.id.through(r.instanceMembers.accountId), @@ -220,6 +281,10 @@ export const relations = defineRelations(schema, (r) => ({ }), }, actors: { + activityLogs: r.many.activityLogs({ + from: r.actors.id.through(r.activityLogActors.actorId), + to: r.activityLogs.id.through(r.activityLogActors.logId), + }), collectionReferences: r.many.actorCollectionReferences({ from: r.actors.id, to: r.actorCollectionReferences.actorId, diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index d7c1ba9..f7897f0 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -18,6 +18,7 @@ import { desc, sql } from "drizzle-orm"; import { type AnyPgColumn, boolean, + bytea, char, check, customType, @@ -34,6 +35,7 @@ import { varchar, } from "drizzle-orm/pg-core"; +import type { PublicJwk } from "./key.ts"; import type { Uuid } from "./uuid.ts"; /** A timestamptz column preserving PostgreSQL's microsecond precision. */ @@ -513,3 +515,267 @@ export const addressing = pgTable( ], ); export type Addressing = typeof addressing.$inferSelect; + +/** Direction of an observed delivery. */ +export const activityLogDirectionEnum = pgEnum("activity_log_direction", [ + "inbound", + "outbound", +]); + +/** + * Inbound: `received` (2xx, listener ran), `acknowledged` (2xx, listener did + * not run), `unverified`, `rejected` (refused although verified); a request + * whose handling threw is one of the last two, without a status code. Outbound: + * `queued` (no attempt ended yet), `sent`, `failed` (the latest attempt + * failed; a queued delivery may be retried), `permanently_failed`, + * `abandoned` (the retry policy ran out). + */ +export const activityLogStatusEnum = pgEnum("activity_log_status", [ + "received", + "acknowledged", + "unverified", + "rejected", + "queued", + "sent", + "failed", + "permanently_failed", + "abandoned", +]); + +/** The mechanism that authenticated an inbound activity. */ +export const activityLogVerificationMechanismEnum = pgEnum( + "activity_log_verification_mechanism", + ["http_signature", "ld_signature", "object_integrity_proof"], +); + +/** + * What Fedify reported of an inbound verification; `unattempted` when it + * answered before verifying, and `unobserved` when recording failed. + */ +export const activityLogVerificationResultEnum = pgEnum( + "activity_log_verification_result", + [ + "verified", + "invalid_signature", + "key_fetch_error", + "no_signature", + "unattempted", + "unobserved", + ], +); + +/** Logical public keys, identified by their exact IRI. */ +export const keys = pgTable("keys", { + id: uuid().$type().primaryKey(), + iri: text().notNull().unique(), + created: instant().notNull().default(currentTimestamp), +}); + +/** Immutable key material, independently retained from Fedify's KV cache. */ +export const keyVersions = pgTable( + "key_versions", + { + id: uuid().$type().primaryKey(), + keyId: uuid("key_id") + .$type() + .notNull() + .references(() => keys.id, { onDelete: "restrict" }), + publicKey: jsonb("public_key").$type().notNull(), + fingerprint: text().notNull(), + /** DrFed observation time, not remote rotation time or evidence of continuous use. */ + firstSeen: instant("first_seen").notNull(), + /** DrFed observation time, not remote rotation time or evidence of continuous use. */ + lastSeen: instant("last_seen").notNull(), + }, + (table) => [ + unique("key_versions_key_id_fingerprint_unique").on( + table.keyId, + table.fingerprint, + ), + check( + "key_versions_seen_check", + sql`${table.lastSeen} >= ${table.firstSeen}`, + ), + check( + "key_versions_public_key_check", + sql`NOT (${table.publicKey} ?| array['d','p','q','dp','dq','qi','oth','k'])`, + ), + index("key_version_key_first_seen_index").on( + table.keyId, + table.firstSeen, + table.id, + ), + ], +); + +/** Delivery observations; payloads may contain unverified, private remote input. */ +export const activityLogs = pgTable( + "activity_logs", + { + id: uuid().$type().primaryKey(), + instanceId: uuid("instance_id") + .$type() + .notNull() + .references(() => instances.id, { onDelete: "cascade" }), + /** The owner of the inbox the request arrived at, or the sending actor. */ + actorId: uuid("actor_id") + .$type() + .references(() => actors.id, { onDelete: "set null" }), + direction: activityLogDirectionEnum().notNull(), + status: activityLogStatusEnum().notNull(), + verificationMechanism: activityLogVerificationMechanismEnum( + "verification_mechanism", + ), + verificationResult: activityLogVerificationResultEnum( + "verification_result", + ), + type: text(), + types: text() + .array() + .notNull() + .default(sql`'{}'`), + activityIri: text("activity_iri"), + objectType: text("object_type"), + objectIri: text("object_iri"), + signedKeyIri: text("signed_key_iri"), + /** A referenced version does not imply successful verification. */ + verificationKeyId: uuid("verification_key_id") + .$type() + .references(() => keyVersions.id, { onDelete: "restrict" }), + /** Inbound, only the first `actor`; the rest are in `payload`. */ + remoteActorIri: text("remote_actor_iri"), + remoteHost: text("remote_host"), + inboxUrl: text("inbox_url").notNull(), + requestUrl: text("request_url"), + /** Names are lowercase; original order and case are not kept. */ + headers: jsonb().$type(), + body: bytea(), + statusCode: integer("status_code"), + responseBody: text("response_body"), + error: text(), + payload: jsonb().$type(), + recipientIris: text("recipient_iris") + .array() + .notNull() + .default(sql`'{}'`), + /** Inbound, when the request arrived; outbound, when delivery started. */ + created: instant().notNull().default(currentTimestamp), + /** Inbound, when DrFed answered; outbound, the latest status change. */ + completed: instant(), + }, + (table) => [ + check( + "activity_logs_direction_status_check", + sql`(${table.direction} = 'inbound' AND ${table.status} IN ('received', 'acknowledged', 'unverified', 'rejected')) OR (${table.direction} = 'outbound' AND ${table.status} IN ('queued', 'sent', 'failed', 'permanently_failed', 'abandoned'))`, + ), + check( + "activity_logs_completed_check", + sql`(${table.completed} IS NULL) = (${table.status} = 'queued')`, + ), + check( + "activity_logs_status_code_check", + sql`${table.statusCode} IS NULL OR ${table.statusCode} BETWEEN 100 AND 599`, + ), + check( + "activity_logs_outbound_key_check", + sql`${table.direction} <> 'outbound' OR ${table.verificationKeyId} IS NULL`, + ), + check( + "activity_logs_verification_result_check", + sql`(${table.direction} = 'inbound') = (${table.verificationResult} IS NOT NULL)`, + ), + check( + "activity_logs_verification_mechanism_check", + sql`${table.verificationMechanism} IS NULL OR (${table.direction} = 'inbound' AND ${table.verificationResult} NOT IN ('unattempted', 'unobserved'))`, + ), + check( + "activity_logs_body_check", + sql`(${table.direction} = 'inbound') = (${table.body} IS NOT NULL)`, + ), + index("activity_log_instance_created_index").on( + table.instanceId, + desc(table.created), + desc(table.id), + ), + index("activity_log_actor_index").on(table.actorId), + index("activity_log_verification_key_index").on(table.verificationKeyId), + index("activity_log_outbound_index") + .on(table.activityIri, table.inboxUrl) + .where(sql`${table.direction} = 'outbound'`), + ], +); + +/** Each ended attempt of an outbound delivery. */ +export const activityLogAttempts = pgTable( + "activity_log_attempts", + { + id: uuid().$type().primaryKey(), + logId: uuid("log_id") + .$type() + .notNull() + .references(() => activityLogs.id, { onDelete: "cascade" }), + succeeded: boolean().notNull(), + statusCode: integer("status_code"), + responseBody: text("response_body"), + error: text(), + /** When the attempt ended. */ + created: instant().notNull().default(currentTimestamp), + }, + (table) => [ + check( + "activity_log_attempts_status_code_check", + sql`${table.statusCode} IS NULL OR ${table.statusCode} BETWEEN 100 AND 599`, + ), + check( + "activity_log_attempts_error_check", + sql`${table.succeeded} = (${table.error} IS NULL)`, + ), + index("activity_log_attempt_log_index").on( + table.logId, + table.created, + table.id, + ), + ], +); + +/** The local actors a log concerns, one row per log and actor. */ +export const activityLogActors = pgTable( + "activity_log_actors", + { + logId: uuid("log_id") + .$type() + .notNull() + .references(() => activityLogs.id, { onDelete: "cascade" }), + actorId: uuid("actor_id") + .$type() + .notNull() + .references(() => actors.id, { onDelete: "cascade" }), + inboxOwner: boolean("inbox_owner").notNull().default(false), + addressed: boolean().notNull().default(false), + sender: boolean().notNull().default(false), + viaCollectionIri: text("via_collection_iri"), + }, + (table) => [ + primaryKey({ columns: [table.logId, table.actorId] }), + check( + "activity_log_actors_role_check", + sql`${table.inboxOwner} OR ${table.addressed} OR ${table.sender}`, + ), + index("activity_log_actor_log_index").on(table.actorId, desc(table.logId)), + ], +); +export type Key = typeof keys.$inferSelect; +export type KeyVersion = typeof keyVersions.$inferSelect; +export type ActivityLog = typeof activityLogs.$inferSelect; +export type NewActivityLog = typeof activityLogs.$inferInsert; +export type ActivityLogAttempt = typeof activityLogAttempts.$inferSelect; +export type ActivityLogActor = typeof activityLogActors.$inferSelect; +export type NewActivityLogActor = typeof activityLogActors.$inferInsert; +export type ActivityLogDirection = + (typeof activityLogDirectionEnum.enumValues)[number]; +export type ActivityLogStatus = + (typeof activityLogStatusEnum.enumValues)[number]; +export type ActivityLogVerificationMechanism = + (typeof activityLogVerificationMechanismEnum.enumValues)[number]; +export type ActivityLogVerificationResult = + (typeof activityLogVerificationResultEnum.enumValues)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eea2ce..f310962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,17 +13,17 @@ catalogs: specifier: ^10.5.0 version: 10.5.0 '@fedify/fedify': - specifier: 2.4.0-pr.1020.43+41cebe9c - version: 2.4.0-pr.1020.43 + specifier: 2.4.0 + version: 2.4.0 '@fedify/pglite': - specifier: 2.4.0-pr.1020.43+41cebe9c - version: 2.4.0-pr.1020.43 + specifier: 2.4.0 + version: 2.4.0 '@fedify/postgres': - specifier: 2.4.0-pr.1020.43+41cebe9c - version: 2.4.0-pr.1020.43 + specifier: 2.4.0 + version: 2.4.0 '@fedify/vocab': - specifier: 2.4.0-pr.1020.43+41cebe9c - version: 2.4.0-pr.1020.43 + specifier: 2.4.0 + version: 2.4.0 '@logtape/drizzle-orm': specifier: ^2.2.2 version: 2.2.2 @@ -115,13 +115,13 @@ importers: version: 0.5.3 '@fedify/fedify': specifier: 'catalog:' - version: 2.4.0-pr.1020.43 + version: 2.4.0 '@fedify/pglite': specifier: 'catalog:' - version: 2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43) + version: 2.4.0(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0) '@fedify/postgres': specifier: 'catalog:' - version: 2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9) + version: 2.4.0(@fedify/fedify@2.4.0)(postgres@3.4.9) '@logtape/drizzle-orm': specifier: 'catalog:' version: 2.2.2(@logtape/logtape@2.3.0-dev.840) @@ -179,19 +179,22 @@ importers: version: 10.5.0 '@fedify/fedify': specifier: 'catalog:' - version: 2.4.0-pr.1020.43 + version: 2.4.0 '@fedify/uri-template': specifier: ^2.3.1 version: 2.3.1 '@fedify/vocab': specifier: 'catalog:' - version: 2.4.0-pr.1020.43 + version: 2.4.0 '@logtape/graphql-yoga': specifier: 'catalog:' version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@logtape/logtape': specifier: 'catalog:' version: 2.3.0-dev.840 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 '@pothos/core': specifier: ^4.13.0 version: 4.13.0(graphql@16.14.2) @@ -298,10 +301,10 @@ importers: version: 1.0.0(solid-js@1.9.14) '@solidjs/start': specifier: ^2.0.0 - version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) nitro: specifier: 3.0.260610-beta - version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) relay-runtime: specifier: ^21.0.1 version: 21.0.1 @@ -326,7 +329,7 @@ importers: version: 20.1.1 eslint-plugin-solid: specifier: ^0.14.5 - version: 0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + version: 0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) relay-compiler: specifier: ^21.0.1 version: 21.0.1 @@ -840,44 +843,44 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} - '@fedify/fedify@2.4.0-pr.1020.43': - resolution: {integrity: sha512-uZvxgWklmBNZ3VQ6YJR7W+zZamwX8+2c1O4h/TZfjRm17ycVcFOHXNcdAFS2TjsVSQpo/axoGt25PI8Wb/+oQA==} + '@fedify/fedify@2.4.0': + resolution: {integrity: sha512-XlR202zMU5+tiISyU48cxe23IU9xXHVPunO+apSSgBgl922JnMNErM/l23HmHEMAtiFiRgiXG+AqC75ZxppWtw==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/pglite@2.4.0-pr.1020.43': - resolution: {integrity: sha512-R3meizlecC1EXHwiNgwCJ6LwtpCvqee46eK7HrCoxA/ll9+/goCgBY0QIYJdhS/EqknrYF1koSKoO8LxposnvQ==} + '@fedify/pglite@2.4.0': + resolution: {integrity: sha512-kTOfxuWQe9FrKz0LYsGOPallpWSOt/aXTOOivgcKidlbOYXT0JnKG8ftmjVNram2vq0cJGHYNev4pWUYX8pO2Q==} peerDependencies: '@electric-sql/pglite': ^0.5.8 - '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c + '@fedify/fedify': ^2.4.0 - '@fedify/postgres@2.4.0-pr.1020.43': - resolution: {integrity: sha512-EGhZXyRFf5oS4fdDdAVUxn1MkAbSxcV99AXZQ8PkH/ejpFNmgcHmsQabefpTl9wqEyg8l8t1M1C0EPvQ5N6t1Q==} + '@fedify/postgres@2.4.0': + resolution: {integrity: sha512-U4E0GKiEXvmpcNk6/97jGhzt+JxxjALlkH0+OPwgcQ6eatVSLKzvVyJ/8a2wRUHHIkgCFo7LO9tLZ+x7GGqxXg==} peerDependencies: - '@fedify/fedify': ^2.4.0-pr.1020.43+41cebe9c + '@fedify/fedify': ^2.4.0 postgres: ^3.4.7 '@fedify/uri-template@2.3.1': resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/uri-template@2.4.0-pr.1020.43': - resolution: {integrity: sha512-2JsU5q+pDNMuoD3ZcJLwubpZWgixwcy0H4yVY/bpz/vEEEWu+g22pa6r52rloWvn5ARJXAkMxcyHDcBlZojpBg==} + '@fedify/uri-template@2.4.0': + resolution: {integrity: sha512-PvYHcbqR/RKjU59RcdIqSWAVfXmpsJ1T+xTfk3zNxJWB/niB4T1MeFPQd5c4x4I6yZ2iQlqdvLCE8v3w0NLXIQ==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab-runtime@2.4.0-pr.1020.43': - resolution: {integrity: sha512-97ctqwEghTbyCU24VuZ14yS9Fs+Wa2dhA9orVV/6hZgalaz8LRUv2mV5wgR2BKddfKmJG5m5i7icV3BkVVwD/w==} + '@fedify/vocab-runtime@2.4.0': + resolution: {integrity: sha512-UW5PCEUNsDFBt70OZCquprAbz5wl5s3GEaaDV8NbpReytAmSsY8GWiXJee2t5yqcIzJCmCgF4stJ/k9oX76jwA==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab-tools@2.4.0-pr.1020.43': - resolution: {integrity: sha512-QL6t5PT/1sTNt8zG/TtggM7XJPKy3/Mem8l+unrt/rtCTTFtK5IO+VhnJz+PXfd61HEeZY680IYt71GSntgU8g==} + '@fedify/vocab-tools@2.4.0': + resolution: {integrity: sha512-7oKOSjfu3QGROwZwblNDL3vtAv4pnhuUPnbwGmvzfEoKPvzHztbXNAFI+cP7I4gtsSac1e/phudR2scoazPB5Q==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab@2.4.0-pr.1020.43': - resolution: {integrity: sha512-fcvwaGXw5BJN28BIyDXQMSr4zS5F/ZWtTN3PFqVNtqGc5B5OdWLP+dwgq6CxAGW+vcC6t+8I84vSZbUML3Jq8A==} + '@fedify/vocab@2.4.0': + resolution: {integrity: sha512-3SRaSi+/Qp8CLR9MWE9qqQUrIZKhnGBoNSTmhvhxkopBk9nms94zYZXsFiQMpv9rdYz3xwJbs8mcI5yqvLX8vg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/webfinger@2.4.0-pr.1020.43': - resolution: {integrity: sha512-tEWdomxujooqZzxXgd1UKGkny+DtObN2eYAFLrQfmy2M1hxEmuyphNNB/ECOWDM9QScDE2P72lJItCDuyWDOiw==} + '@fedify/webfinger@2.4.0': + resolution: {integrity: sha512-wXdY9nbnbNMzDcuSfymx6pddvE8Ikm+YbamKm/JA0il1Z3xdnuBEdAN4BgQ/aKnW8vhCyqw4jjXp9UZUNPMD6Q==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} '@floating-ui/core@1.8.0': @@ -3918,20 +3921,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@7.2.0)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3960,19 +3963,19 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -3993,9 +3996,9 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -4006,7 +4009,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@7.2.0)': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -4014,7 +4017,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4256,17 +4259,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.4(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@7.2.0)': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4279,10 +4282,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4306,12 +4309,12 @@ snapshots: '@fastify/busboy@3.2.0': {} - '@fedify/fedify@2.4.0-pr.1020.43': + '@fedify/fedify@2.4.0': dependencies: - '@fedify/uri-template': 2.4.0-pr.1020.43 - '@fedify/vocab': 2.4.0-pr.1020.43 - '@fedify/vocab-runtime': 2.4.0-pr.1020.43 - '@fedify/webfinger': 2.4.0-pr.1020.43 + '@fedify/uri-template': 2.4.0 + '@fedify/vocab': 2.4.0 + '@fedify/vocab-runtime': 2.4.0 + '@fedify/webfinger': 2.4.0 '@logtape/logtape': 2.3.2 '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) @@ -4328,26 +4331,26 @@ snapshots: temporal-polyfill: 1.0.4 urlpattern-polyfill: 10.1.0 - '@fedify/pglite@2.4.0-pr.1020.43(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0-pr.1020.43)': + '@fedify/pglite@2.4.0(@electric-sql/pglite@0.5.3)(@fedify/fedify@2.4.0)': dependencies: '@electric-sql/pglite': 0.5.3 - '@fedify/fedify': 2.4.0-pr.1020.43 + '@fedify/fedify': 2.4.0 '@logtape/logtape': 2.3.2 '@types/emscripten': 1.41.6 temporal-polyfill: 1.0.4 - '@fedify/postgres@2.4.0-pr.1020.43(@fedify/fedify@2.4.0-pr.1020.43)(postgres@3.4.9)': + '@fedify/postgres@2.4.0(@fedify/fedify@2.4.0)(postgres@3.4.9)': dependencies: - '@fedify/fedify': 2.4.0-pr.1020.43 + '@fedify/fedify': 2.4.0 '@logtape/logtape': 2.3.2 postgres: 3.4.9 temporal-polyfill: 1.0.4 '@fedify/uri-template@2.3.1': {} - '@fedify/uri-template@2.4.0-pr.1020.43': {} + '@fedify/uri-template@2.4.0': {} - '@fedify/vocab-runtime@2.4.0-pr.1020.43': + '@fedify/vocab-runtime@2.4.0': dependencies: '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.3.2 @@ -4358,18 +4361,18 @@ snapshots: jsonld: 9.0.0 pkijs: 3.4.0 - '@fedify/vocab-tools@2.4.0-pr.1020.43': + '@fedify/vocab-tools@2.4.0': dependencies: '@cfworker/json-schema': 4.1.1 byte-encodings: 1.0.11 es-toolkit: 1.46.1 yaml: 2.9.0 - '@fedify/vocab@2.4.0-pr.1020.43': + '@fedify/vocab@2.4.0': dependencies: - '@fedify/vocab-runtime': 2.4.0-pr.1020.43 - '@fedify/vocab-tools': 2.4.0-pr.1020.43 - '@fedify/webfinger': 2.4.0-pr.1020.43 + '@fedify/vocab-runtime': 2.4.0 + '@fedify/vocab-tools': 2.4.0 + '@fedify/webfinger': 2.4.0 '@logtape/logtape': 2.3.2 '@multiformats/base-x': 4.0.1 '@opentelemetry/api': 1.9.1 @@ -4379,9 +4382,9 @@ snapshots: pkijs: 3.4.0 temporal-polyfill: 1.0.4 - '@fedify/webfinger@2.4.0-pr.1020.43': + '@fedify/webfinger@2.4.0': dependencies: - '@fedify/vocab-runtime': 2.4.0-pr.1020.43 + '@fedify/vocab-runtime': 2.4.0 '@logtape/logtape': 2.3.2 '@opentelemetry/api': 1.9.1 es-toolkit: 1.46.1 @@ -5046,10 +5049,10 @@ snapshots: dependencies: solid-js: 1.9.14 - '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@solidjs/start@2.0.0(@solidjs/router@1.0.0(solid-js@1.9.14))(crossws@0.4.10(srvx@0.11.16))(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@solidjs/meta': 0.29.4(solid-js@1.9.14) '@types/babel__traverse': 7.28.0 @@ -5073,7 +5076,7 @@ snapshots: srvx: 0.12.5 terracotta: 1.1.1(solid-js@1.9.14) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + vite-plugin-solid: 2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) optionalDependencies: '@solidjs/router': 1.0.0(solid-js@1.9.14) transitivePeerDependencies: @@ -5148,11 +5151,11 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/project-service@8.62.1(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/project-service@8.62.1(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -5168,13 +5171,13 @@ snapshots: '@typescript-eslint/types@8.62.1': {} - '@typescript-eslint/typescript-estree@8.62.1(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/typescript-estree@8.62.1(typescript@7.0.2)': dependencies: - '@typescript-eslint/project-service': 8.62.1(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/project-service': 8.62.1(typescript@7.0.2) '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@7.0.2) '@typescript-eslint/types': 8.62.1 '@typescript-eslint/visitor-keys': 8.62.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 @@ -5183,13 +5186,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/utils@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.62.1 '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(supports-color@7.2.0)(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0) typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -5410,19 +5413,19 @@ snapshots: pvutils: 1.2.0 tslib: 2.8.1 - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@7.2.0)): + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/types': 7.29.8 html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14): + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.14 @@ -5546,11 +5549,9 @@ snapshots: '@electric-sql/pglite': 0.5.3 drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)) - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 deep-is@0.1.4: {} @@ -5679,10 +5680,10 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2): + eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2): dependencies: - '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) - eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2) + eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 is-html: 2.0.0 kebab-case: 1.0.2 @@ -5703,14 +5704,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0): + eslint@9.39.4(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5(supports-color@7.2.0) + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -5720,7 +5721,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5959,11 +5960,11 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.11.1(supports-color@7.2.0): + ioredis@5.11.1: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -6181,7 +6182,7 @@ snapshots: nf3@0.3.23: {} - nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1(supports-color@7.2.0))(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + nitro@3.0.260610-beta(@electric-sql/pglite@0.5.3)(chokidar@5.0.0)(dotenv@17.4.2)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2)))(giget@3.3.0)(ioredis@5.11.1)(jiti@2.7.0)(lru-cache@11.5.1)(rollup@4.62.2)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.16) @@ -6196,7 +6197,7 @@ snapshots: rolldown: 1.2.0 srvx: 0.11.16 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 giget: 3.3.0 @@ -6637,10 +6638,10 @@ snapshots: '@corvu/utils': 0.4.2(solid-js@1.9.14) solid-js: 1.9.14 - solid-refresh@0.6.3(solid-js@1.9.14)(supports-color@7.2.0): + solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7 '@babel/types': 7.29.8 solid-js: 1.9.14 transitivePeerDependencies: @@ -6851,11 +6852,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1(supports-color@7.2.0))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))))(ioredis@5.11.1)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 db0: 0.3.4(@electric-sql/pglite@0.5.3)(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.9)(valibot@1.4.2(typescript@7.0.2))) - ioredis: 5.11.1(supports-color@7.2.0) + ioredis: 5.11.1 lru-cache: 11.5.1 ofetch: 2.0.0-alpha.3 @@ -6907,14 +6908,14 @@ snapshots: transitivePeerDependencies: - typescript - vite-plugin-solid@2.11.14(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.14) + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) merge-anything: 5.1.7 solid-js: 1.9.14 - solid-refresh: 0.6.3(solid-js@1.9.14)(supports-color@7.2.0) + solid-refresh: 0.6.3(solid-js@1.9.14) vite: 8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.0(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ce0a1d..2b31fdf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,10 +9,10 @@ allowBuilds: catalog: "@electric-sql/pglite": ^0.5.3 "@faker-js/faker": ^10.5.0 - "@fedify/fedify": 2.4.0-pr.1020.43+41cebe9c - "@fedify/postgres": 2.4.0-pr.1020.43+41cebe9c - "@fedify/pglite": 2.4.0-pr.1020.43+41cebe9c - "@fedify/vocab": 2.4.0-pr.1020.43+41cebe9c + "@fedify/fedify": 2.4.0 + "@fedify/postgres": 2.4.0 + "@fedify/pglite": 2.4.0 + "@fedify/vocab": 2.4.0 "@logtape/drizzle-orm": ^2.2.2 "@logtape/graphql-yoga": 2.3.0-dev.840 "@logtape/logtape": 2.3.0-dev.840 @@ -35,14 +35,14 @@ catalog: uuid: ^14.0.1 minimumReleaseAgeExclude: - - "@fedify/fedify@2.4.0-pr.1020.43" - - "@fedify/postgres@2.4.0-pr.1020.43" - - "@fedify/uri-template@2.4.0-pr.1020.43" - - "@fedify/vocab-runtime@2.4.0-pr.1020.43" - - "@fedify/vocab-tools@2.4.0-pr.1020.43" - - "@fedify/vocab@2.4.0-pr.1020.43" - - "@fedify/webfinger@2.4.0-pr.1020.43" - - "@fedify/pglite@2.4.0-pr.1020.43" + - "@fedify/fedify@2.4.0" + - "@fedify/postgres@2.4.0" + - "@fedify/uri-template@2.4.0" + - "@fedify/vocab-runtime@2.4.0" + - "@fedify/vocab-tools@2.4.0" + - "@fedify/vocab@2.4.0" + - "@fedify/webfinger@2.4.0" + - "@fedify/pglite@2.4.0" - "@logtape/graphql-yoga@2.3.0-dev.840" - "@logtape/logtape@2.3.0-dev.840" - "@logtape/testing-node@2.3.0-dev.840"